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.

Engine version dependency

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:

<!-- elk: one pane rendered at a time -->
<view class="explore-tabs">
  <view class="explore-tab" @tap="tab = 'posts'">…</view>
  <view class="explore-tab" @tap="tab = 'tags'">…</view>
</view>
<list v-if="tab === 'posts'" @scrolltolower="loadNext">…</list>
<list v-else-if="tab === 'tags'" @scrolltolower="loadMoreTags">…</list>

…become a component with one named slot per pane:

<!-- elk-viewpager: all panes live, swipeable -->
<TabPager v-model="tab" :tabs="tabs">
  <template #posts>…</template>
  <template #tags>…</template>
</TabPager>

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:

- let pager = signedIn ? makePaginator() : empty;
- const items = ref([]);          // one active feed, reset on tab switch
+ const feeds = reactive({         // one feed per pane, retained across swipes
+   all:     { items: [], state: 'idle' },
+   mention: { items: [], state: 'idle' },
+ });

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:

<list
  scroll-orientation="vertical"
  :lower-threshold-item-count="4"
  @scrolltolower="loadNext"
>
  <list-item
    v-for="status in items"
    :key="status.id"
    :item-key="status.id"
    :estimated-main-axis-size-px="160"
  >
    <StatusCard :status="status" />
  </list-item>
</list>

Three attributes do the heavy lifting:

PieceRole
estimated-main-axis-size-pxLets the list lay out the scroll range before each cell measures
lower-threshold-item-countHow many items from the end before we ask for more
@scrolltolowerFires usePaginator.loadNext() — Elk's masto.js Paginator iteration, unchanged

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:

LayerVerdictNotes
masto.js API client✅ reused unpatchedwrapper-injected native fetch is synchronized with web globalThis.fetch; remaining constructors are targeted through source.define, with fill-if-missing native shims
Content pipeline (content-parse.ts)✅ ~95% verbatimultrahtml sanitize + custom-emoji / markdown / mention-collapse transforms
Content renderer (content-render.ts)♻️ retargetedsame AST walk; emits <text>/<image> runs with tap navigation instead of <p>/<a>/RouterLink
Paginator, timeline filters, status actions, search, cache✅ reusedDOM scroll trigger → native <list> scrolltolower
Virtual scrolling♻️ replacedElk's DOM virtualizer (virtua) → Lynx's native recycling <list>less code
Routing♻️ rebuiltNuxt file routes → explicit vue-router table on createMemoryHistory, same route shapes so content-renderer mention/hashtag rewrites work unchanged
Every template♻️ rebuilt<div><view>, <span><text>, @click@tap, Elk's exact theme palette as Lynx CSS vars
Icons♻️ adaptedElk's RemixIcon set (i-ri:*) rendered as tinted XML through Lynx's built-in <svg content> element
Native safe area✅ adaptedfullscreen iOS cards consume Sparkling topHeight / bottomHeight global props, with Lynx Explorer alias support

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> with estimated-main-axis-size-px and @scrolltolower (not <scroll-view>); masto.js Paginator iteration and Elk's reorder/buffer logic drive loadNext() 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.