---
title: Virtual List
url: "https://native-sdk.dev/docs/components/virtual-list"
docs_index: /llms.txt
lastUpdated: 2026-10-16
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

Use the Zig builder’s virtual list to build only visible rows and overscan while representing the full dataset in a scroll region. Call `ui.virtualWindow` for the visible index range, build keyed rows for that range, and pass them to `ui.virtualList`. The runtime calculates the window from retained scroll state, viewport height, and fixed or estimated row extents; the model owns the dataset.

## Markup

Windowed construction is available only through the Zig builder. Markup cannot bind a runtime window request or an extent-estimate function. A markup [virtualized list](/docs/components/list) builds all authored rows and limits layout and painting to the visible window. Markup scroll regions can use `on-reach-end` to request more data.

## Programmatic construction (Zig)

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.

```zig
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.

| Attribute      | Description                                                                                                                                                                                                                                                                                                                             |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `on-reach-end` | scroll 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`):

<table>
  <thead>
    <tr>
      <th>
        Option
      </th>

      <th>
        Description
      </th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>
        `id`
      </td>

      <td>
        Stable list identity: becomes the element's

        `global_key`

        and the key the runtime resolves scroll state by. Unique per tree.
      </td>
    </tr>

    <tr>
      <td>
        `item_count`
      </td>

      <td>
        Total items currently in the model. Content extent, scrollbar, and

        `list_item_index/count`

        semantics derive from it.
      </td>
    </tr>

    <tr>
      <td>
        `item_extent`
      </td>

      <td>
        Fixed per-item extent in points. The runtime calculates the range and total extent without measuring every row. With

        `extent_estimate`

        set it becomes the fallback estimate only (may be 0).
      </td>
    </tr>

    <tr>
      <td>
        `gap`
      </td>

      <td>
        Vertical gap between rows (part of the row stride).
      </td>
    </tr>

    <tr>
      <td>
        `overscan`
      </td>

      <td>
        Rows built beyond the visible range on each side (default 4): scroll slack until the next window rebuild lands.
      </td>
    </tr>

    <tr>
      <td>
        `viewport_fallback`
      </td>

      <td>
        Viewport height assumed when no runtime scroll state exists (bare builds); under

        `UiApp`

        the installed window source falls back to the canvas height instead.
      </td>
    </tr>

    <tr>
      <td>
        `width`

        /

        `height`

        /

        `min_width`

        /

        `grow`

        /

        `padding`
      </td>

      <td>
        The container's box, as on any element.
      </td>
    </tr>

    <tr>
      <td>
        `style`

        /

        `style_tokens`

        /

        `semantics`
      </td>

      <td>
        Container styling and semantics; the role defaults to

        `list`

        .
      </td>
    </tr>

    <tr>
      <td>
        `on_scroll`
      </td>

      <td>
        Optional scroll observation (

        `Ui.scrollMsg`

        constructor). Not required for windowing.
      </td>
    </tr>

    <tr>
      <td>
        `on_reach_end`
      </td>

      <td>
        The approach-end Msg (see above).
      </td>
    </tr>

    <tr>
      <td>
        `extent_estimate`

        /

        `extent_context`
      </td>

      <td>
        Variable-extent mode: a pure estimate function (

        `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.
      </td>
    </tr>

    <tr>
      <td>
        `index_base`
      </td>

      <td>
        Logical 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.
      </td>
    </tr>

    <tr>
      <td>
        `anchor`
      </td>

      <td>
        `.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.
      </td>
    </tr>

    <tr>
      <td>
        `on_reach_start`
      </td>

      <td>
        The approach-START Msg (load older history), with the same hysteresis as reach-end; a prepend re-arms it by growing the offset.
      </td>
    </tr>

    <tr>
      <td>
        `overscroll`
      </td>

      <td>
        Edge 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.
      </td>
    </tr>
  </tbody>
</table>

## Variable-extent rows

Chat transcripts, mixed-height feeds, and markdown-bearing lists set `extent_estimate` instead of a fixed `item_extent`. The estimate supplies extents for unmounted rows; 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. Account for two behaviors:

- **The scrollbar may drift as estimates correct.** The correction store has a fixed budget. Evicted rows use their estimates until they are 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.

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)