Skip to content

VirtualList

VirtualList is the low-level component for long lists and custom row rendering. It is available with the virtual-list feature. It reuses tui-widget-list and calls render_item only for the visible window, so it can handle thousands or tens of thousands of rows without first building a complete ListItem collection.

virtual list demo

This recording is generated by docs/tapes/virtual-list.tape. The example loads 10,000 rows, uses default_index to restore the cursor to row 43, moves repeatedly, submits the current row, and switches to the empty-list state.

cargo run --example virtual_list

Press j/k to move, Home/End to jump to the start or end, Enter to submit the current index, e to toggle the empty state, and q to quit.

VirtualList is behind the virtual-list feature:

ratatui-kit = { version = "...", features = ["virtual-list"] }

Examples use the full feature, so they can be run directly. The library default feature set is empty; when validating this component, use --all-features.

use ratatui_kit::components::tui_widget_list::ListBuildContext;

VirtualList<Line<'static>>(
    item_count: 10_000,
    default_index: Some(42),
    render_item: |context: &ListBuildContext| {
        let row = context.index + 1;
        let style = if context.is_selected {
            Style::new().fg(Color::Black).bg(Color::Green)
        } else {
            Style::new()
        };

        (Line::styled(format!("row {row:05}"), style), 1u16)
    },
    on_select: move |index: usize| {
        submitted.set(format!("selected row {}", index + 1));
    },
)

render_item returns (widget, main_axis_size). In a vertical list, main_axis_size is row height; in a horizontal list, it is column width. The component requests only the rows needed for the visible region, so render_item should do lightweight, deterministic row construction.

By default, VirtualList stores its own tui_widget_list::ListState. If the page needs to display the current cursor, implement virtual multi-select, or let the parent handle extra keys, pass external state:

let list_state = hooks.use_state(ListState::default);
let cursor = cursor_label(list_state.read().selected);

VirtualList<Line<'static>>(
    state: list_state,
    item_count: item_count,
    default_index: Some(42),
    on_select: move |index| {
        submitted.set(format!("selected row {}", index + 1));
    },
)

on_select returns the current index, not row data. That keeps the component from owning your business collection; you can use the index to read from your own data source, jump to details, or update a selection set.

default_index is an index, not a value. It supports “empty first, loaded later”: an empty list does not permanently swallow the default cursor; when data returns and there is no current cursor, the component selects the default index. Once the user has moved the cursor, list length changes do not force the cursor back to the default.

When item_count becomes shorter, the component clamps an out-of-bounds cursor to the last valid item. When item_count is 0, it does not consume navigation or confirm keys, so the parent component can still handle page-level shortcuts.

VirtualList can receive width, height, margin, and offset directly. Its border still uses the native Block from tui-widget-list:

VirtualList<Line<'static>>(
    width: Constraint::Length(42),
    block: Block::bordered()
        .border_style(Style::new().fg(Color::Cyan))
        .title_top(Line::from(" build log ").centered()),
    scroll_padding: 2,
    infinite_scrolling: false,
)

scroll_padding scrolls early before the cursor reaches the edge. infinite_scrolling controls whether the list wraps at the start/end.

Prefer MultiSelect for ordinary multi-select. When the list is long, rows need custom rendering, or the selection set must be fully controlled by business code, combine VirtualList with an external HashSet<usize>; Virtual Multi Select demonstrates this pattern.

VirtualList does not include loading/empty text or business themes. Compose those at the page level: while loading, pass item_count: 0 and render status outside; when data returns, restore the real count.