Scrolling and Viewports

A list of notes, a horizontal shelf, and an illustrated story all need a way to show content that extends beyond the available space. Scroller gives that content a viewport, clips it at the edges, and connects it to the platform's scrolling behavior on web, macOS, iOS, and iPadOS.

The scroll position is also a property. You can use it to return to the top, show reading progress, or move through an animation as the reader explores.

This chapter builds on Layout and property handles and bindings. For Rust handlers, see Event Handling.

Viewport, content, and position

A Scroller has three related sets of dimensions:

PropertyMeaning
width, heightThe visible viewport, placed by ordinary Pax layout
scroll_width, scroll_heightThe extent of the scrollable content pane
scroll_pos_x, scroll_pos_yThe offset into that pane, as numeric pixel values

Here is a 240-pixel-tall viewport onto 720 pixels of notes. Paste this into src/lib.pax in a project whose Rust main component imports pax_kit::*:

<Scroller x=24px y=24px width={100% - 48px} height=240px
    scroll_width=100% scroll_height=720px corner_radius=16>
    <Group width=100% height=720px>
        for i in 0..4 {
            <Group y={(i * 180)px} width=100% height=180px>
                <Text x=20px y=20px width={100% - 40px} height=40px
                    text={"Field note " + (i + 1)}
                    style={font: "Arial", font_size: 24px, fill: rgb(36, 54, 47)} />
                <Rectangle x=8px y=8px width={100% - 16px} height=164px
                    corner_radius=12 fill=rgb(225, 234, 220) />
            </Group>
        }
    </Group>
</Scroller>

Increasing scroll_pos_y moves the visible window farther down the content; the notes move upward on screen. At rest, the vertical range here is 0 through 720 - 240 = 480 pixels. If the content fits inside the viewport, there is no travel on that axis. Platform overscroll and bounce can temporarily present the edges differently.

scroll_height=300% would also describe 720 pixels for this viewport: percentages on the scroll extent are relative to the Scroller's own viewport. The extent establishes how far you can scroll; it does not stretch the child tree to that size. A direct child at height=100% still receives the viewport-height layout frame. The explicit 720-pixel Group above gives its descendants a content-sized coordinate space.

Scroller does not paint a background. Place painted content inside it, or a background sibling behind it, depending on which should move. Earlier Pax siblings appear in front; that is why each note's Text precedes its Rectangle. corner_radius is a numeric pixel radius for the viewport clip.

Autosized Scrollers

For a document that grows as you add content, let Scroller measure its pane:

<Scroller x=24px y=24px width={100% - 48px} height=240px
    scroll_width=100% autosize=true corner_radius=16>
    <Stacker width=100% autosize=true gutter=12px>
        for i in 0..5 {
            <Text width=100% height=80px text={"Measured note " + (i + 1)}
                style={font: "Arial", font_size: 24px, fill: rgb(36, 54, 47)} />
        }
    </Stacker>
</Scroller>

The stack measures five 80-pixel children and four 12-pixel gutters. Scroller uses that 448-pixel content height while its viewport remains 240 pixels tall. Changing the children or their measured sizes updates the scrollable extent.

autosize=true on Scroller manages the vertical content extent by default. Horizontal size continues to use scroll_width; autosize_x=true opts into horizontal measurement. autosize_y=false turns off vertical measurement even when autosize=true.

On a measured axis, Scroller uses its resolved content measurement in preference to scroll_height or scroll_width; the explicit value is a fallback if that measurement cannot be resolved. The public size property remains the input, so reading self.some_scroll_height does not automatically give you the measured result. This differs from asking a container to measure its own outer dimensions.

Give measured content a useful starting point: concrete row heights, text measurement, or an autosized stack. Avoid making an expanding document depend only on height=100%. Font loading and native measurements can change the settled extent. Layout's autosize section explains the shared measurement rules.

Read and set the scroll position

Use bind: to share the Scroller's position with a component property. User scrolling updates that property; setting it from Rust requests a new position. This also gives buttons and visual indicators one place to read the state.

In src/lib.rs:

#![allow(unused)]
fn main() {
use pax_kit::*;

#[pax]
#[main]
#[file("lib.pax")]
pub struct Example {
    pub scroll_y: Property<f64>,
}

impl Example {
    pub fn back_to_top(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
        self.scroll_y.set(0.0);
    }

    pub fn last_note(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
        self.scroll_y.set(480.0);
    }
}
}

In src/lib.pax:

<Button x=24px y=24px width=120px height=36px label="Back to top"
    @button_click=self.back_to_top />
<Button x=156px y=24px width=120px height=36px label="Last note"
    @button_click=self.last_note />
<Text x=24px y=72px width=280px height=28px
    text={"Offset: " + self.scroll_y}
    style={font: "Arial", font_size: 18px, fill: rgb(36, 54, 47)} />

<Scroller x=24px y=120px width={100% - 48px} height=240px
    scroll_width=100% scroll_height=720px scroll_pos_y=bind:self.scroll_y>
    <Group width=100% height=720px>
        for i in 0..4 {
            <Text x=16px y={(i * 180 + 16)px} width={100% - 32px} height=80px
                text={"Field note " + (i + 1)}
                style={font: "Arial", font_size: 24px, fill: rgb(36, 54, 47)} />
        }
        <Rectangle width=100% height=100% fill=rgb(225, 234, 220) />
    </Group>
</Scroller>

Use plain numbers for these offsets: 480.0, rather than 480px. Their Rust type is f64; the extent properties use Size and accept px or %.

The example's 480-pixel destination comes from its known dimensions. For a responsive document, derive destinations from current layout and content, and keep them within the available travel. Content shrinkage and viewport resizing can constrain what the native host can show. Avoid repeatedly writing a saved position while the person is actively scrolling; native gesture updates and application writes would compete for the same state.

Position versus scroll events

@scroll sends an Event<Scroll> with delta_x and delta_y. It is a shared delta stream used by wheel/touch input and native position notifications. It is useful when an action needs movement information. Use the bound scroll_pos_y for reading progress or a return position, rather than building a second position by accumulating event deltas.

Native position notifications report movement that has already happened. They are not a cancellable, cross-platform “before scrolling” hook. Likewise, @wheel describes wheel input and does not cover every way someone can scroll. See Event Handling for event binding and propagation.

Horizontal regions and snapping

For a horizontal shelf, make scroll_width larger than width and keep the vertical extent at 100%. The same geometry works on both axes.

Snap positions add landing points. This strip contains three viewport-width panels and snaps at their starts:

<Scroller x=24px y=24px width={100% - 48px} height=180px
    scroll_width=300% scroll_height=100%
    snap_positions_x=[0px, 100%, 200%] corner_radius=16>
    for i in 0..3 {
        <Group x={(i * 100)%} anchor_x=0% width=100% height=100%>
            <Text x=20px y=20px width={100% - 40px} height=48px
                text={"Panel " + (i + 1)}
                style={font: "Arial", font_size: 24px, fill: rgb(36, 54, 47)} />
            <Rectangle x=6px width={100% - 12px} height=100%
                corner_radius=16 fill=rgb(225, 234, 220) />
        </Group>
    }
</Scroller>

Each panel uses anchor_x=0% so its percentage position locates its left edge. Snap percentages resolve against the viewport on the corresponding axis. snap_positions_y provides the vertical equivalent. Leave the lists empty for ordinary continuous scrolling. Web uses native CSS scroll snapping; Apple targets choose native scroll endpoints from the supplied positions. Gesture momentum and settling can differ between platforms.

Carousel packages page layout, content extents, and snapping. Each supplied child becomes a page:

<Carousel x=24px y=24px width={100% - 48px} height=220px
    axis=CarouselAxis::Horizontal page_size=100% show_dots=true>
    <Rectangle width=100% height=100% fill=rgb(225, 234, 220) />
    <Rectangle width=100% height=100% fill=rgb(242, 218, 183) />
    <Rectangle width=100% height=100% fill=rgb(205, 224, 236) />
</Carousel>

Horizontal paging and page_size=100% are the defaults. Choose CarouselAxis::Vertical for vertical pages, and bind scroll_pos_x or scroll_pos_y when the application needs the position. page_size controls each page's extent along the scrolling axis; its percentage is relative to the Carousel viewport. With only one child, that page fills the viewport.

Dots are optional position indicators, hidden when there is only one page. They are not clickable navigation controls. Provide explicit previous/next buttons when your interface needs them, using the bound scroll position.

Scroll-driven motion

Scrolling can reveal a drawing, turn a diagram, or carry a caption through a sequence. Start with a normalized position:

progress = scroll position / (content extent - viewport extent)

Clamp the result to 0..1 and handle a zero-length scroll range. Then map progress to the timeline's playhead range. The dimensions in the state example give a travel of 480 pixels, so this addition to its lib.pax makes a reading-progress bar. Add the Group before the Scroller and place the timeline at the end of the file:

<Group x=24px y=104px width={100% - 48px} height=6px>
    <Rectangle id=reading_progress height=100% fill=rgb(56, 100, 78) />
    <Rectangle width=100% height=100% fill=rgb(210, 218, 206) />
</Group>

@timeline reading {
    duration: 100,
    playhead: {Math::min(1, Math::max(0, self.scroll_y / 480)) * 100},
    loop: false,
    #reading_progress {
        width: {
            0%: 0%, Linear,
            100%: 100%,
        },
    },
}

Here duration: 100 defines the timeline's sampling range. There is no running clock: scrolling backward samples earlier positions, and resting leaves the bar still. A responsive version needs current viewport and content measurements in place of the fixed 480 denominator. Keep the Scroller's geometry independent of the decorative animation so the progress mapping does not change its own scroll range.

The same playhead can drive several tracks. The canonical scroll-garden example binds a vertical Carousel to a playhead for its articulated scenes. Animation and Motion explains track ownership, easing, and playback; Drawing owns Path and Handwriter reveals.

Nested viewports and native content

A horizontal shelf can live inside a vertical document. Give each Scroller a bounded viewport and its own content extent. For example, place the horizontal strip above inside a taller content Group in the outer Scroller. Its width then follows that group's width, while its 180-pixel height remains a visible window onto the strip.

Use nesting when the regions have distinct jobs. Same-axis nesting makes gesture ownership harder to anticipate; test what happens at each edge with the target's trackpad, mouse, and touch input. Pax relies on the platform scroll container for native scrolling, momentum, and gesture arbitration.

Text, form controls, vectors, and images can share the scrolling content tree. The native host moves the content, while Pax maintains the rendering, clipping, and input-coordinate relationships. A web root Scroller that fits the viewport and scrolls only vertically can delegate to page scrolling; you still use Scroller properties rather than browser DOM operations.

For a toolbar that stays in place, keep it outside the Scroller as a sibling, usually reserving space for it in the surrounding layout. A child at y=0px belongs to the scrolling content and moves with it. layout_role=LayoutRole::Breakout removes a child from the relevant layout measurement/placement rules; it does not let that child escape the Scroller's clipping or rendering tree.

Use NodeContext::local_point for custom pointer interaction inside moving content. On iOS and iPadOS, scroll recognition can suppress a tap while the child still receives its lower-level touch sequence. The touches-inside-a-Scroller section explains how to clear transient feedback. Check native controls and nested gestures together on the targets you ship.

Scroller-owned rendering surfaces also matter for overlays. A translucent root Rectangle is not a universal dimmer over every native scroll region. Compositing owns the cross-surface explanation and modal-underlay pattern.

Large collections and practical checks

Pax uses viewport-aware drawing and tiled surfaces to limit rendering work for scrollable content. That does not make for a virtualized list: repeated children still participate in tree expansion, properties, and lifecycle. Offscreen components may still cost startup time and perform application work.

Start with realistic collection sizes and measure first paint as well as scrolling. For a large data set, consider application-level paging or loading bounded batches. Stable keys preserve item identity during changes; they do not defer offscreen initialization. See Components for collection ownership.

Exercise the finished view with its real text, images, and controls:

  • Resize it and change the content count, including an empty or short list.
  • Reach the first and last items, then use a position button and scroll again.
  • Try nested regions, native-control interaction, and rounded edges.
  • Test fast movement on the actual browser/backend and Apple devices you support. Startup cost and tile presentation remain workload- and target-dependent; a desktop web check cannot establish every target's behavior.

From the repository root, these examples offer larger inspection surfaces:

pax-cli run --path examples/src/rounded-scroller-tiles --target web
pax-cli run --path examples/src/scroll-matrix --target web
pax-cli run --path examples/src/scroll-garden --target web

Run one at a time. rounded-scroller-tiles focuses on rounded viewports and mixed content; scroll-matrix exercises nesting, transforms, and controls; scroll-garden explores scroll-driven scenes. Their source lives under examples/src/ and remains the place to follow the complete applications.

Read more