Elk — a Mastodon Client
To prove Vue Lynx can carry a real product-grade app, we ported
Elk — the beloved Mastodon web client
by Anthony Fu and team — into a native Mastodon client. It browses any
public Mastodon instance as a guest (default mas.to): timelines, threads,
profiles, search, trends, dark mode and more.
Try it below. The Web tab runs the real app on Lynx for Web against a
live instance; the QR Code tab runs the same main.lynx.bundle
natively — scan it with Lynx Go / Lynx Explorer (see
Quick Start to install one).
Native viewpager variant newer Lynx engine
The elk-viewpager fork backs the Explore and Notifications tabs with the
native <viewpager>
element instead of conditionally rendered panes. In
TabPager.vue
each pane is a <viewpager-item>: panes swipe horizontally with a native
snap animation, keep content and scroll position across swipes, and the tab
bar syncs both ways — a swipe fires the pager's change event (→ active
tab), a tab tap calls its selectTab method (→ animate to that page). It
feels much closer to a native client.
<viewpager> is a Lynx XElement,
registered under a different tag per platform, so TabPager.vue picks the
tag at runtime from SystemInfo.platform (highlighted below):
<x-viewpager-ng> on Lynx for Web, the extracted <viewpager> /
<viewpager-item> on native OSS engines.
The extracted <viewpager> landed in the OSS engine in lynx-family/lynx
c1d8d7920 (2026-04) — newer than any released LynxExplorer. 3.8.1 and
earlier register neither <viewpager> nor <x-viewpager-ng>, so the tab
area renders blank (with a LynxCreateUIException). Run this variant on a
host built from lynx develop; on released Explorers, use the default Elk
example above, whose conditional tabs need no pager element.
How much code does the native upgrade take?
Almost none. Moving the whole app onto the native pager touches three
files: the new TabPager.vue plus the two pages with swipeable tabs
(ExplorePage.vue, NotificationsPage.vue). The other ~55 source files —
masto.js client, content renderer, router, virtualized <list> — are
byte-for-byte identical.
At the call site it's a straight swap. The hand-rolled tab bar and
v-if / v-else-if panes, where only the active pane exists:
…become a component with one named slot per pane:
That v-if → slot rewrite is the whole UI-side story — tab bar, underline
animation, and pager ↔ tab sync all move into TabPager.
One change isn't mechanical, and it captures what "native paging" really
means. With v-if, only the visible pane exists, so a single shared
paginator was enough. With the pager, every pane is mounted at once and
swipeable — so each tab keeps its own data and scroll position.
Notifications goes from one shared feed to one per tab:
That's the trade: you give up "render only what's visible," and the panes stay warm — swipe away and back, and you're exactly where you left off, no reload.
Going further: a collapsing profile
The profile page takes the pattern one step further into "native profile"
territory. AccountPage
wraps the same viewpager in Lynx's collapsing-header coordinator
(StickyTabView.vue):
scroll down and the banner/bio/stats header collapses, the tab bar
pins to the top, and the Posts / Replies / Media panes below keep paging
horizontally, each with its own feed and scroll position. The coordinator
stacks as header (collapses) + toolbar (sticky tabs) + slot (the viewpager),
and its nested scroll folds the header first, then hands off to the active
pane's list. Like the viewpager it's registered per platform — the legacy
<x-foldview-ng> on Lynx for Web, the extracted
<scroll-coordinator>
on native OSS engines.
Native feed techniques
A Mastodon client is mostly long lists: home, local, federated, explore,
notifications, search, profile posts, followers. On the web Elk virtualizes
with virtua and triggers the next page
from a DOM end-anchor's bounding box. Lynx gives you two scroll primitives —
and which one you pick is the whole performance story.
<scroll-view> mounts everything
<scroll-view> is the
right tool for short, heterogeneous screens: Settings, Compose, a status
thread, a media sheet. Every child stays mounted; you scroll a normal layout
tree. That is also why it is the wrong tool for a timeline — once the
federated feed grows past a few dozen statuses, you are paying for every card
above and below the viewport.
<list> recycles and paginates
<list> is Lynx's recycling
scroller. Off-screen <list-item> cells are reused; you only keep a window of
real nodes alive. Infinite scroll is a native event, not a geometry poll:
Three attributes do the heavy lifting:
TimelinePaginator.vue
is the shared feed shell: first paint, error/retry, the recycling list, and a
footer spinner / "End of the timeline". Timelines, Explore posts, hashtag
pages, bookmarks/favourites, and profile Posts / Replies / Media all reuse it.
Explore tags/news, notifications, followers, and search wire the same
<list> + scrolltolower pattern to their own item templates.
<scroll-view> still appears where recycling would not help — thread detail,
settings forms, the compose sheet. The rule of thumb the port settled on:
if it can grow without bound, it is a <list>; if it is a finite page, keep
<scroll-view>.
Why Elk is a serious test
Elk is a Nuxt 3 app with ~196 components, 55 pages and 50 composables. Vue Lynx has no Nuxt (no SSR, file routing, Nitro, auto-imports) and no DOM — so instead of forking, the port reuses Elk's framework-agnostic layers and rebuilds the UI on Lynx elements:
The full feature-parity checklist (including what's deliberately not ported and why — OAuth redirects, TipTap, PWA, Shiki, blurhash…) lives in PRD.md, the architecture map in PORTING.md, and side-by-side screenshot comparisons against the original elk.zone in screenshots/.
Highlights
- The content renderer is the crown jewel: Mastodon statuses arrive as
sanitized HTML. Elk parses them into an AST and renders vnodes; the port
keeps the parse step byte-for-byte and only swaps the vnode targets —
custom emoji become inline
<image>, mentions/hashtags become tappable<text>runs that push vue-router routes. - Native virtualized timeline: feeds use Lynx
<list>withestimated-main-axis-size-pxand@scrolltolower(not<scroll-view>); masto.jsPaginatoriteration and Elk's reorder/buffer logic driveloadNext()unchanged across timelines, explore, profiles, and search. - Deep links: pass
globalProps: { initialPath: '/mas.to/tags/caturday' }to the LynxView and the app opens on that route — the same mechanism a host app would use for notification taps. - Guest + token sessions: browse anonymously like Elk's guest mode, or paste a personal access token in Settings to unlock home timeline, notifications, posting, boosts and favourites.