Skip to content

ScrollView

ScrollView is a scrollable container. It does not change how you declare child components: you still compose content with ordinary View, Text, and Border; those children are simply rendered into a larger internal buffer first, then displayed through the visible viewport according to the current offset.

scrollview demo

This recording is generated by docs/tapes/scrollview.tape. The example shows a controlled document reader: the left side is a ScrollView, and the right side displays the live ScrollViewState offset. Press j/k to scroll by line and PageDown/PageUp to page; the state panel updates with every move.

cargo run --example scrollview

Press j/k or arrow keys to scroll line by line, PageDown/PageUp to page, Home/End to jump to the top or bottom, and q to quit.

The minimal form does not require you to store scroll state yourself:

ScrollView(
    flex_direction: Direction::Vertical,
    block: Block::bordered(),
) {
    for (index, row) in rows.into_iter().enumerate() {
        View(key: index, height: Constraint::Length(1)) {
            Text(text: row)
        }
    }
}

When state is not passed, ScrollView creates its own ScrollViewState internally and registers scroll events on the current input layer. It supports j/k, arrow keys, PageUp/PageDown, Home/End, and the mouse wheel.

Built-in scrolling is gated only by active (default true); it is orthogonal to state, so passing external state does not turn it off — you can hold a handle and keep the built-in keys, matching the other selection components. The internal handler now returns EventResult::Consumed for the keys it actually scrolled on (and Ignored for everything else), so unrelated shortcuts on the same layer — page-level q to quit, ? to open help — still run.

When a page needs to read the offset, reset the scroll position, or connect scrolling to other UI, pass external state:

let scroll_state = hooks.use_state(ScrollViewState::default);

hooks.use_event_handler_with_options(
    EventScope::Current,
    EventPriority::Normal,
    EventOptions { hit_test: true },
    move |event| {
        let Event::Key(key) = &event else {
            return EventResult::Ignored;
        };

        match key.code {
            KeyCode::Down | KeyCode::Char('j') | KeyCode::Up | KeyCode::Char('k') => {
                scroll_state.write().handle_event(&event);
                EventResult::Consumed
            }
            KeyCode::PageDown | KeyCode::PageUp | KeyCode::Home | KeyCode::End => {
                scroll_state.write().handle_event(&event);
                EventResult::Consumed
            }
            _ => EventResult::Ignored,
        }
    },
);

ScrollView(
    state: scroll_state,
    scrollbars: Scrollbars {
        vertical_scrollbar_visibility: ScrollbarVisibility::Always,
        horizontal_scrollbar_visibility: ScrollbarVisibility::Never,
        ..Default::default()
    },
) {
    // rows
}

The example uses external state: j/k move a selected cursor and the viewport follows it via scroll_state.write().scroll_to_index(selected); the state panel reads scroll_state.read().offset().

ScrollViewState also exposes size() / page_size(), is_at_bottom() (for stick-to-bottom logs/chat), scroll_to_visible(y, height), and — the follow-the-selection primitive — scroll_to_index(i) / child_area(i), which scroll the i-th direct child into view. ScrollView records each direct child’s content-buffer area every frame, so after a page moves its selection over a list of children it just calls scroll_to_index(selected). Child geometry is selection-independent, so no extra measure pass is needed. handle_event returns bool (whether it acted), so business handlers can map it to Consumed / Ignored.

ScrollView content size comes from child layout constraints. In the example, every row has an explicit height:

View(key: index, height: Constraint::Length(1)) {
    Text(text: row.line())
}

If child nodes do not have stable heights, scroll distance becomes hard to predict. For long documents, logs, shortcut tables, and read-only detail pages, making each row Constraint::Length(1) is usually easiest to control.

The block prop draws borders and titles outside the scroll viewport:

ScrollView(
    block: Block::bordered()
        .title(Line::from(" controlled document ").centered())
        .border_style(Style::new().cyan()),
)

Scrollbars are controlled through Scrollbars. Automatic shows only when content overflows; Always reserves stable space; Never fits cases where you explicitly do not need scrolling in that direction.

With a bordered block, Scrollbars::over_border (default true) draws the scrollbar on the block’s border ring — the track sits on the right/bottom border line, and the content keeps the full inner area. Set it to false to inset the scrollbar inside the border instead, reducing the content viewport by one row/column:

ScrollView(
    block: Block::bordered(),
    scrollbars: Scrollbars { over_border: false, ..Default::default() },
)

The scrollview example toggles this at runtime (press b) so you can compare the two looks. Either way the offset is clamped against the true visible viewport, so the last row/column is always reachable.

ScrollView registers its handler in EventScope::Current. When placed inside Modal or ShortcutInfoModal, it naturally belongs to the foreground modal’s current layer, so scrolling inside the modal does not pass through to the background page.

If you only need to display shortcut groups, prefer ShortcutInfoModal. If you need to render a very long selectable list, see VirtualList.