Cloud|

@k2b/ui

VirtualFeed

Long feeds that load at both ends, stay at the end, and keep the reading position to the pixel.

VirtualFeed@k2b/ui

5,000 events with day separators, a marker, older pages loading at the top, and Jump to latest. Scroll up, then add events: nothing moves.

TSX

Copy
<VirtualFeed
items={events()}
getKey={(event) => event.id}
estimateSize={() => 40}
label="Release activity"
separator={(event, previous) => (previous && sameDay(event.at, previous.at) ? undefined : day(event.at))}
markerKey={firstUnseenId()}
hasOlder={hasOlder()}
onLoadOlder={loadOlder}
controller={(controller) => (feed = controller)}
>
{(event) => <EventRow event={event} />}
</VirtualFeed>

VirtualFeed shows a long, time-ordered feed of items with different heights, such as messages, activity, or log events. It renders only the items near the visible area, loads more items at both ends, and keeps the reading position: while the reader is at the end, the feed stays there as items arrive and rows or the surrounding layout change size. A reader who scrolled up stays on the same item, to the pixel, whatever loads or grows around it.

The application owns the items, their order, paging, and every row's content. The component owns scrolling, measuring, the reading position, the "Jump to latest" overlay, and the feed semantics for assistive technology.

For a conversation, render each item with MessageRow; its rows have their final height when they mount.

Import

tsx
import { VirtualFeed, type VirtualFeedController, type VirtualFeedProps } from "@k2b/ui";

Use VirtualFeed

Pass the loaded items oldest first, a stable getKey, an estimateSize in pixels, a label, and a render function as children. Replace the items array to add, remove, or change items; the feed compares keys to tell an append from a prepend or another change. A changed item object with the same key renders again, and keeps focus when its row had it. Keys must be unique. The render function runs once per item object, as with Solid's For; read signals inside its JSX to update parts of a row.

Give the feed a bounded height, for example as the growing part of a flex column. Content below it, such as a composer, belongs outside the feed. When that content grows or an on-screen keyboard shortens the page, the feed keeps its end in view while the reader is there. When the reader has scrolled up, the item at the bottom edge stays in place.

estimateSize only needs to be close. Every row is measured when it mounts, and the feed makes up the difference before the frame is drawn, so what the reader sees does not move. Close estimates make the scrollbar steadier during fast scrolling. Give rows their final height at mount where possible, for example with reserved space for images of known size. Content that grows later is handled, but it moves the rows below it within the visible area.

Load more at both ends

Set hasOlder and onLoadOlder to load older items when the reader nears the start, and hasNewer and onLoadNewer for the end. Prepend or append the page to items; the visible item stays exactly where it was. The feed calls each callback once until its promise settles and marks itself busy meanwhile. It also loads when hasOlder or hasNewer turns on while the reader is at that edge, so a reader at the end follows newer items reported by a live update. Handle errors inside the callback; a rejected promise ends the busy state, and when a load toward the end brought nothing, "Jump to latest" shows again. A failed load is not repeated right away; the feed asks again when the reader scrolls at that edge, or when the items or the layout change.

When the newest items are not loaded, for example after jumping to an old item, pass onLoadNewest. "Jump to latest" then calls it once running loads have finished, starts no other page meanwhile, and lands at the end of the new items. Without onLoadNewest, the feed follows the end while onLoadNewer pages arrive, until the newest item is loaded or the reader scrolls away.

When newer items appear that only onLoadNewest can load, or a load toward the end fails, the feed stops following the end where it is, and "Jump to latest" shows.

Jump to an item

The controller callback receives a VirtualFeedController once the feed is mounted:

  • scrollToKey(key, { align, highlight }) scrolls to a loaded item and returns false if no loaded item has that key, so you can load a page around it first and call it again. align is "center" (default) or "start"; highlight tints the item briefly. The item stays in place afterwards.
  • scrollToEnd() follows the end again. Call it after the reader sends an item, so the item shows even while the feed still coasts from a fling.
  • isAtEnd() tells whether new items keep the feed at the end.

onEndChange reports when the reader reaches or leaves the end, for example to mark items as seen.

Separators and the marker

separator(item, previous) returns a short label shown above an item, such as a date when the day changes; return nothing for no separator. While hasOlder is set, the first loaded item has no known predecessor and gets no separator until the older page arrives. markerKey names the first item the reader has not seen yet and shows a "New" label above it; change the text with markerLabel. Both are part of their item's row. When one appears or goes away, the content of the item at the top of the view stays where it is.

Jump to latest

While the reader is away from the end, an overlay button "Jump to latest" appears above the bottom edge. It counts the new items added since the reader left the end; pass newCount to show your own number instead. Items are new when they come after the newest item the feed had loaded while no newer items existed. Pages of older history that onLoadNewer brings in after a jump do not count. The button overlays the feed, so it never changes the layout.

Long feeds

The feed lays out at most about 8 million pixels at once and moves this window as the reader scrolls, so a feed of 100,000 items scrolls normally in every engine. Firefox would draw a taller element with no height at all. The scrollbar therefore covers the laid-out window, not every loaded item. A focused item that the window leaves behind is unmounted, and focus moves to the scroll area.

Reading position

The feed follows the end while the reader is there. Otherwise it keeps the item at the top of the view where it is. When the view gets shorter or taller, for example when an on-screen keyboard opens, the item at its bottom edge stays in place. A scroll step that reveals an item above in the same frame as that item changes size moves the items the reader saw by the step alone: the item that was at the top before the step keeps its place.

What counts as the reader

Every change of the scroll position that the feed did not make is the reader's, whatever caused it: touch and momentum, the wheel, keys, the scrollbar, focus moving into the feed, scrollIntoView, find in page, or assistive technology. The feed keeps the new position and leaves or reaches the end with it. Three moves are not the reader's:

  • the scroll event of the feed's own write;
  • a clamp: when a size change shortens the scroll range, the engine moves the position onto the new end. The feed treats that as the size change, so a reader a few pixels above the end stays there;
  • what is left of a momentum after the application jumped with scrollToEnd() or scrollToKey(), and other scrolls without the reader's input in that short time (see "Jumps during a scroll").

The end is judged in the geometry the position was last applied to. A scroll step that lands in the same frame as a viewport resize counts against the height from before the resize. Items appended while the reader scrolls away from the end do not move the end the reader is heading for. A scroll that reaches that end follows the end. The new items show with the feed's next correction, at the latest when the scroll ends, and from then on the end includes them: a reader who moves up again leaves the end.

During a scroll

From the first touch or scroll until the scroll ends, the feed does not write the scroll position when rows grow or shrink, items are appended, or the viewport resizes: a write would fight the finger, its momentum, or a scroll animation. The feed moves the rows instead, so what the reader sees stays where the reader put it in every frame. Touches are followed on the element they began on, so a touch ends even when the feed removed that row meanwhile.

Near the start, moved rows would show empty space above the first item, or put the first items out of the scroll range's reach. Within 600 px of the start, the feed therefore writes corrections at once, also during a scroll, together with any correction it held back until the reader got there. That write moves nothing on screen either.

A scroll ends at the engine's scrollend when no movement follows in the next two frames. Engines also fire scrollend for the feed's own writes, and iOS delivers it late enough to land in a later fling, even in a pause of its slow tail. The first scrollend within 1 second of a write that moved the position is therefore that write's, and a scrollend followed by movement does not count. When no scrollend comes, the scroll ends 1 second after its last move; in engines without scrollend, after 300 ms. A finger that lifts after its scroll ended ends it when no momentum follows in the next two frames. When the scroll ends, the feed writes the held-back correction in one step that moves nothing on screen.

Structural changes are corrected at once, also during a scroll: pages loaded above the reader, removed or reordered items, the loaded window moving, and keyboard moves between items.

Jumps during a scroll

scrollToEnd() and scrollToKey() win over a scroll that is still coasting. The feed jumps at once and stops the momentum where the engine allows it. It puts back what is left of the momentum until nothing has moved for 300 ms, or until the reader touches, clicks, wheels, or presses a key. Until then, other scrolls that no such input started are put back as well, for example from focus that the application moves without preventScroll, scrollIntoView, or assistive technology.

Accessibility

The feed uses the feed role with your label, and every item is an article, named by itemLabel when you pass it. Items carry aria-posinset and aria-setsize. Pass firstPosition and totalCount when you know them. Without firstPosition, positions are left out while hasOlder is set. Without totalCount, the size is -1 while hasOlder or hasNewer is set. The feed is aria-busy while busy is set or a page loads.

One item is in the tab order, by default the newest visible one. Arrow keys and Page Up or Page Down move between items and scroll along, Ctrl+Home goes to the first loaded item, and Ctrl+End to the end. The focused item stays mounted while the reader scrolls elsewhere in the laid-out window, so focus never falls back to the page. The scroll area itself is not a tab stop.

New items at the end are announced in a separate, visually hidden log region, bundled per second ("3 new items"). Pass announce to word it yourself or to stay silent, for example for the reader's own items: it gets the added items and returns the text or nothing. Items loaded at the start, pages of older history loaded toward the end, and replaced items are not announced. Announcements still pending when items switches to a different feed are dropped.

Runtime

The server renders the empty scroll area with its label; rows appear after hydration, because they need the browser to measure them. Pass empty for content shown while items is empty.

Example

tsx
const [entries, setEntries] = createSignal<Entry[]>(latestPage);
let feed: VirtualFeedController | undefined;

<div style={{ display: "flex", "flex-direction": "column", height: "32rem" }}>
  <VirtualFeed
    items={entries()}
    getKey={(entry) => entry.id}
    estimateSize={(entry) => (entry.preview ? 120 : 44)}
    label="Release activity"
    itemLabel={(entry) => `${entry.author}, ${entry.time}`}
    separator={(entry, previous) => (entry.day !== previous?.day ? entry.day : undefined)}
    markerKey={firstUnseenId()}
    hasOlder={hasOlder()}
    onLoadOlder={async () => {
      const page = await loadBefore(entries()[0]);
      setEntries((current) => [...page, ...current]);
    }}
    controller={(controller) => (feed = controller)}
  >
    {(entry) => <ActivityRow entry={entry} />}
  </VirtualFeed>
  <ReplyField />
</div>;

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.