4.3k

Virtual List

The windowed virtual list: a scroll region that represents the model's whole dataset while the view only ever builds the rows on screen. The runtime owns the viewport math — retained scroll offset + viewport height → visible index range, from a fixed per-item extent (uniform rows) or an app-provided extent estimate corrected by measurement (variable rows) — and the model owns the data: the view asks ui.virtualWindow for the range, builds one keyed node per item in it, and hands both to ui.virtualList. Widget-node cost is the window plus overscan, never the dataset, so a 100,000-row timeline stays deep under the per-view node budget while the scrollbar (engine-drawn, or the native scroll driver's OS overlay on macOS) spans the full virtual extent.

virtual-list
A windowed virtual list rendered by the engine (light theme)
2,500 rows exist as arithmetic; the tree holds only the visible window

Zig builder

Declare the list options once and use them twice — virtualWindow resolves which items are visible, virtualList mounts exactly those rows. Keys carry per-item identity: a row that scrolls away and back returns under the same structural id, so engine-owned and model-owned row state survive window shifts.

const options = Ui.VirtualListOptions{
    .id = "timeline",              // stable identity (global key + scroll-state lookup)
    .item_count = model.loaded,    // the MODEL owns the data; this is all the runtime sees
    .item_extent = 84,             // fixed row height: the uniform fast path
    .overscan = 4,                 // rows built beyond the visible range on each side
    .grow = 1,
    .on_reach_end = .load_more,    // the infinite-scroll fetch signal
};
const window = ui.virtualWindow(options);
const rows = ui.arena.alloc(Ui.Node, window.itemCount()) catch return ...;
for (rows, 0..) |*row, offset| {
    const index = window.start_index + offset;
    var node = postRow(ui, model, index); // read model data for ONE item
    node.key = .{ .int = @intCast(index) };
    row.* = node;
}
return ui.virtualList(options, window, .{rows});

No on_scroll binding is required: the runtime owns the offset (wheel, kinetic, keyboard, and the native scroll driver all land on it), and every scroll observation re-derives the view so the window follows. Bind on_scroll only when the model wants to observe the position.

The data-window seam

ui.virtualWindow is a value the view reads during build — the same direction of flow as the appearance and window-chrome channels, never a callback into the model. Under UiApp the window source resolves the list's retained scroll state by its declared id; on the first build (no state yet) it assumes offset 0 at the canvas height, and if the freshly laid-out geometry proves the window under-covered (first build, a window resize), the view is derived once more against it — the window converges within the same rebuild. In bare tree builds (tests, previews) with no source installed, viewport_fallback stands in.

Reach-end

on_reach_end is the infinite-fetch signal for any scroll container (markup: on-reach-end on scroll): dispatched when a user scroll comes within one viewport of the content end, with hysteresis — it fires once per approach and re-arms only after the offset retreats past one and a half viewports, which appending a batch causes on its own by growing the extent. A programmatic jump to the end fires once and never re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it. update appends the next batch; nothing else is required.

AttributeDescription
on-reach-endscroll element only: Msg (tag or tag:{payload}) dispatched when a user scroll comes within one viewport of the content end - the infinite-scroll fetch signal. Fires once per approach with hysteresis: it re-arms only after the offset retreats past 1.5 viewports, which appending a batch causes on its own by growing the extent.

Options

Ui.VirtualListOptions fields (shared by virtualWindow and virtualList):

OptionDescription
idStable list identity: becomes the element's global_key and the key the runtime resolves scroll state by. Unique per tree.
item_countTOTAL items the model holds right now. Content extent, scrollbar, and list_item_index/count semantics derive from it.
item_extentFixed per-item extent in points — the uniform fast path: the visible range and the 100k-row extent are pure arithmetic, no measurement pass. With extent_estimate set it becomes the fallback estimate only (may be 0).
gapVertical gap between rows (part of the row stride).
overscanRows built beyond the visible range on each side (default 4): scroll slack until the next window rebuild lands.
viewport_fallbackViewport height assumed when no runtime scroll state exists (bare builds); under UiApp the installed window source falls back to the canvas height instead.
width / height / min_width / grow / paddingThe container's box, as on any element.
style / style_tokens / semanticsContainer styling and semantics; the role defaults to list.
on_scrollOptional scroll observation (Ui.scrollMsg constructor). Not required for windowing.
on_reach_endThe approach-end Msg (see above).
extent_estimate / extent_contextVariable-extent mode: a cheap pure estimate fn (fn (context, logical_index) f32) from model facts — line counts, byte counts — never from layout. Rows lay out at their intrinsic heights; the engine measures mounted rows and converges the scroll geometry, anchored so corrections never move visible content.
index_baseLogical index of item 0 (prepend-stable identity). Decrease it by the prepended count to load older history: row identity, measured extents, and the viewport anchor all survive.
anchor.leading (default) or .trailing — the chat contract: open at the bottom, stay pinned through appends while the user sits at the bottom, never yank a scrolled-away viewport.
on_reach_startThe approach-START Msg (load older history), with the same hysteresis as reach-end; a prepend re-arms it by growing the offset.
overscrollEdge behavior of the list's scroll region. .default follows the ScrollPhysics.overscroll token — off unless a theme flips it, so scrolling pins at the content edges; .rubber_band lets this list bounce past them; .none pins it regardless of the token.

Variable-extent rows

Chat transcripts, mixed-height feeds, and markdown-bearing lists set extent_estimate instead of a fixed item_extent. The estimate prices unmounted rows (rough is fine); mounted rows lay out at their real wrapped heights and the engine records those measurements into a budgeted per-list offset table, so the total extent — the scrollbar, the scroll semantics, the native driver's content size — converges to truth as the user visits the list. Two honest behaviors to know:

  • The scrollbar may drift as estimates correct. That is the industry-honest contract of every estimated virtual list; the correction store is budgeted, and rows evicted from it drift back to their estimates until revisited.
  • Visible content never jumps. The built window is anchored on the first visible row: it lands exactly at the offset table's edge for it, freshly mounted rows stack above/below at their real heights, and every correction shifts the scroll offset and the geometry together. Estimate error surfaces off screen, not under the pointer.

anchor = .trailing, on_reach_start, and index_base complete the transcript shape — see examples/feed for the mixed-height showcase and the options table above for each field.

Markup

The windowed virtual list is builder-only: the closed markup grammar has no channel for the view to receive the runtime's range request inside a for binding, so markup cannot build "only the visible rows" honestly — and the variable-extent mode additionally needs an estimate function, which the closed grammar has no binding form for. Markup lists keep the bounded form — <list virtualized> lays out only the visible window of the rows the for produced, which is the right shape when the row set already fits the model (hundreds, not hundreds of thousands). on-reach-end IS markup-expressible on scroll, so markup apps get honest infinite fetch with bounded windows.