7.1k

Scroll

A scroll view: wrap multiple children in a single column inside it. The engine owns wheel, kinetic, and keyboard scrolling and draws the scrollbar while a scroll is in flight; on-scroll names a Msg variant with a canvas.ScrollState payload — or, in a transpiled TypeScript core, a declared record of the same two-axis fields (offset_x/offset_y, velocity_x/velocity_y, viewport_extent_x/viewport_extent_y, content_extent_x/content_extent_y), matched by name — that delivers the post-scroll offsets and viewport/content extents on both axes, so the model can observe position without owning it. Echo offset_y into a model field bound as value and the model owns the position too: setting the field scrolls the region (the controlled-scroll shape).

axis declares which axes the region scrolls: vertical (the default), horizontal, or both. A horizontal grant opts the region into wheel/trackpad delta_x, a bottom-edge scrollbar, and the horizontal keymap — Left/Right step in lines, and a horizontal-only region takes Home/End/PageUp/PageDown on its one axis too. The horizontal offset rides value-x, the sideways counterpart of value with the same source-wins reconcile. Nested regions route each wheel axis independently: every axis of a scroll gesture travels to the nearest ancestor that scrolls on that axis, so inside a horizontal timeline holding a vertical list, delta_y scrolls the list while delta_x reaches the timeline — one diagonal gesture, two regions, no fighting. Virtualized scrolls stay vertical (windowed virtualization prices rows, not columns). Scrolling pins at the content edges by default — no rubber-band bounce; kinetic motion stops cleanly at the boundary. overscroll="rubber_band" opts one region into bouncing past its edges (both the engine physics and the native macOS scroller honor it), and the ScrollPhysics.overscroll design token flips the app-wide default, which per-region values override. on-reach-end dispatches a plain Msg when a scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (appending a batch grows the extent and re-arms the next approach). 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. Pair with list for layout-culled rows, or the builder's virtual list for dataset-scale windows.

scroll
A scroll region rendered by the engine (light theme)
a fixed-height scroll region; the engine draws the scrollbar during scrolling

Markup

<scroll height="240" padding="8" on-scroll="log_scrolled">
  <column gap="2">
    <list-item on-press="open_entry">Changelog entry 14</list-item>
    <list-item on-press="open_entry">Changelog entry 13</list-item>
    <list-item on-press="open_entry">Changelog entry 12</list-item>
    <list-item on-press="open_entry">Changelog entry 11</list-item>
    <list-item on-press="open_entry">Changelog entry 10</list-item>
    <list-item on-press="open_entry">Changelog entry 9</list-item>
    <list-item on-press="open_entry">Changelog entry 8</list-item>
  </column>
</scroll>

The arm receives the scroll state and stores what the model wants to remember — echo offsetY into a field bound as the scroll's value (and offsetX into value-x on horizontal-capable regions) and the model owns the position too:

import { type ScrollState } from "@native-sdk/core/events";

export type Msg = /* ... */ | { readonly kind: "log_scrolled"; readonly scroll: ScrollState };
// in update:
case "log_scrolled":
  return { ...model, changelog_offset: msg.scroll.offsetY };
// Msg arm: log_scrolled: canvas.ScrollState
.log_scrolled => |scroll| model.changelog_offset = scroll.offset_y,

A horizontal shelf declares its axis and reads the X fields:

<scroll axis="horizontal" height="180" on-scroll="shelf_scrolled">
  <row gap="8">
    <panel width="140"><text>Cover 1</text></panel>
    <panel width="140"><text>Cover 2</text></panel>
    <panel width="140"><text>Cover 3</text></panel>
  </row>
</scroll>

Programmatic construction (Zig)

In a Zig view, the canvas.Ui builder constructs the same tree programmatically. on_scroll pairs with Ui.scrollMsg(.tag); the delivered offset is the value the runtime already applied, so echoing it back never fights the scroll reconcile.

ui.scroll(.{ .height = 240, .padding = 8, .on_scroll = Ui.scrollMsg(.log_scrolled) }, .{
    ui.column(.{ .gap = 2 }, .{
        ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 14"),
        ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 13"),
        ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 12"),
        ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 11"),
        ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 10"),
    }),
})

Attributes

AttributeDescription
on-scrollscroll element only: names a Msg variant with canvas.ScrollState payload; delivers the post-scroll two-axis state (offset_x/offset_y, velocity_x/velocity_y, viewport_extent_x/viewport_extent_y, content_extent_x/content_extent_y) after wheel, kinetic, keyboard, and accessibility scrolls.
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.
axisscroll only: which axes the region scrolls - vertical (the default), horizontal, or both. Horizontal grants opt the region into wheel/trackpad delta-x, the bottom-edge scrollbar, and the horizontal keymap; in a nested tree each wheel axis routes independently to the nearest ancestor scrolling that axis. Virtualized scrolls stay vertical (a horizontal grant there is a teaching error).
value-xscroll only, beside axis="horizontal" or axis="both": the horizontal scroll offset - the sideways counterpart of value, following the same source-wins reconcile rule (echo on-scroll's offset_x back to keep user scrolling; move it model-side to scroll programmatically). Without a horizontal axis grant it is a teaching error (it would be silently inert).
overscrollscroll only: edge behavior of the region. none pins scrolling at the content edges (the shipped default via the ScrollPhysics.overscroll token), rubber_band lets this region bounce past them, default follows the token. Honored by the engine's scroll physics and the native OS scroller alike.
heightDefinite height (plain number): the element is exactly this tall; content neither shrinks nor overflows it.
widthDefinite width (plain number): the element is exactly this wide; content neither shrinks nor overflows it. On resizable it is the initial width.
paddingUniform padding (plain number).