Scroll Components

ScrollView, ScrollBar, ScrollBarBox, and ScrollList for building scrollable terminal UIs

Scroll Components

@visulima/tui provides two approaches to scrolling:

  1. CSS-level scrolling via Box props (overflow="scroll", scrollTop, sticky) — integrated into the layout engine with native scrollbar rendering and sticky header support. See the Box component docs.
  2. Component-based scrolling via ScrollView, ScrollList, etc. — higher-level React components with imperative APIs, selection management, and alignment modes.

When to use which

FeatureBox overflow="scroll"ScrollView component
ScrollbarBuilt-in (scrollbar prop, on by default)Built-in (scrollbar prop)
Scroll positionYou manage via scrollTop/scrollLeft propsManaged internally, control via ref (scrollBy, scrollTo)
Keyboard navigationYou handle via useInputBuilt-in (keyboard prop, optional vimBindings)
Sticky headersYes (sticky prop on children)No
Selection trackingNoYes (via ScrollList with selectedIndex)
Follow output (log tailing)NoYes (followOutput prop)
VirtualizationNoYes (virtualize prop)
Reach callbacksNoYes (onReachEnd/onReachStart)
Best forSimple overflow with sticky headersLists, dynamic content, keyboard-driven UIs

Important: These are separate scroll systems. Do not nest ScrollView inside a Box with overflow="scroll" — the Box scrollbar will not reflect ScrollView's scroll position because they track scroll state independently. Use one or the other, not both.

Component-Based Scroll

Ported from the ByteLand scroll ecosystem. Import from @visulima/tui.

import { ControlledScrollView, ScrollBar, ScrollBarBox, ScrollList, ScrollView } from "@visulima/tui";

ScrollView

A scrollable viewport that manages its own scroll state. Control it imperatively via a ref.

!Scrolling Demo

import { useRef } from "react";
import { Box, ScrollView, Text, useInput } from "@visulima/tui";
import type { ScrollViewRef } from "@visulima/tui";

const Demo = () => {
    const scrollRef = useRef<ScrollViewRef>(null);

    useInput((input, key) => {
        if (key.downArrow) scrollRef.current?.scrollBy(1);
        if (key.upArrow) scrollRef.current?.scrollBy(-1);
    });

    return (
        <Box height={10} borderStyle="single">
            <ScrollView ref={scrollRef}>
                {items.map((item) => (
                    <Text key={item.id}>{item.label}</Text>
                ))}
            </ScrollView>
        </Box>
    );
};

Dynamic Items

!Dynamic Items Demo

Expand / Collapse

!Expand Demo

Terminal Resize

!Resize Demo

Width Changes (Text Wrapping)

!Width Demo

Props

Extends BoxProps.

PropTypeDescription
onScrollfunctionCallback with current scroll offset
onViewportSizeChangefunctionFires when viewport dimensions change
onContentHeightChangefunctionFires when total content height changes
onItemHeightChangefunctionFires when an individual item resizes
debugbooleanDisables overflow hidden for debugging
keyboardbooleanEnable built-in keyboard navigation (default: false)
vimBindingsbooleanEnable vim-style keys j/k/g/G/u/d (requires keyboard, default: false)
followOutputbooleanAuto-scroll to bottom when content grows (default: false)
followThresholdnumberLines from bottom that count as "at bottom" for followOutput (default: 3)
onReachEndfunctionFires once when scrolled within reachThreshold of the end
onReachStartfunctionFires once when scrolled within reachThreshold of the start
reachThresholdnumberDistance in lines to trigger reach callbacks (default: 5)
scrollbarbooleanShow a scrollbar when content overflows (default: false)
scrollbarColorstringColor of the scrollbar thumb and track
virtualizebooleanOnly render visible items + overscan (default: false)
overscannumberExtra items to render outside viewport when virtualized (default: 3)

Ref Methods

MethodDescription
scrollTo(offset)Scroll to absolute position
scrollBy(delta)Scroll by relative amount
scrollToTop()Scroll to top
scrollToBottom()Scroll to bottom
getScrollOffset()Current scroll position
getContentHeight()Total content height
getViewportHeight()Visible viewport height
getBottomOffset()Max scroll offset
getItemHeight(i)Height of item at index
getItemPosition(i)Position and height of item at index
remeasure()Re-measure viewport dimensions
remeasureItem(i)Re-measure a specific child

ControlledScrollView

Lower-level variant where the parent owns the scrollOffset state.

import { useState } from "react";
import { Box, ControlledScrollView, Text, useInput } from "@visulima/tui";

const Demo = () => {
    const [offset, setOffset] = useState(0);

    useInput((input, key) => {
        if (key.downArrow) setOffset((o) => o + 1);
        if (key.upArrow) setOffset((o) => Math.max(0, o - 1));
    });

    return (
        <Box height={10} borderStyle="single">
            <ControlledScrollView scrollOffset={offset}>
                {items.map((item) => (
                    <Text key={item.id}>{item.label}</Text>
                ))}
            </ControlledScrollView>
        </Box>
    );
};

ScrollBar

A visual scroll position indicator. Works in two placement modes:

  • Border mode (placement="left" or "right"): Integrates with a container's border.
  • Inset mode (placement="inset"): Placed inside the content area.

Border Mode

!Border Mode Demo

Inset Mode

!Inset Mode Demo

Auto Hide

!Auto Hide Demo

import { Box, ScrollBar, Text } from "@visulima/tui";

<Box flexDirection="row">
    <Box borderStyle="single" borderRight={false} height={10}>
        <Text>Content here</Text>
    </Box>
    <ScrollBar placement="right" style="single" contentHeight={100} viewportHeight={10} scrollOffset={scrollOffset} />
</Box>;

Props

PropTypeDefaultDescription
contentHeightnumberrequiredTotal content height
viewportHeightnumberrequiredVisible viewport height
scrollOffsetnumberrequiredCurrent scroll position
placementScrollBarPlacement'right''left', 'right', or 'inset'
styleScrollBarStylevariesVisual style
thumbCharstring---Custom thumb character (inset only)
trackCharstring---Custom track character (inset only)
autoHidebooleanfalseHide when content fits (inset only)
colorstring---Scroll bar color
dimColorbooleanfalseDimmed styling

ScrollBarBox

A Box with a built-in scroll bar on one border.

import { ScrollBarBox, Text } from "@visulima/tui";

<ScrollBarBox height={12} borderStyle="single" contentHeight={50} viewportHeight={10} scrollOffset={scrollOffset}>
    {visibleItems.map((item) => (
        <Text key={item.id}>{item.label}</Text>
    ))}
</ScrollBarBox>;

Props

Extends BoxProps.

PropTypeDefaultDescription
contentHeightnumberrequiredTotal content height
viewportHeightnumberrequiredVisible viewport height
scrollOffsetnumberrequiredCurrent scroll position
scrollBarPosition'left' | 'right''right'Which side to show the bar
scrollBarAutoHidebooleanfalseHide thumb when content fits
thumbCharstring---Custom thumb character

ScrollList

A ScrollView with externally controlled selection and automatic scroll-into-view.

Selection & Navigation

!Selection Demo

Scroll Alignment Modes

!Alignment Demo

Expand / Collapse

!Expand Demo

Dynamic Items

!Dynamic Demo

import { useRef, useState } from "react";
import { Box, ScrollList, Text, useInput } from "@visulima/tui";

const Demo = () => {
    const [selected, setSelected] = useState(0);
    const items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"];

    useInput((input, key) => {
        if (key.downArrow) setSelected((i) => Math.min(i + 1, items.length - 1));
        if (key.upArrow) setSelected((i) => Math.max(i - 1, 0));
    });

    return (
        <ScrollList height={5} selectedIndex={selected}>
            {items.map((item, i) => (
                <Box key={i}>
                    <Text color={i === selected ? "blue" : "white"}>
                        {i === selected ? "> " : "  "}
                        {item}
                    </Text>
                </Box>
            ))}
        </ScrollList>
    );
};

Props

Extends ScrollViewProps.

PropTypeDefaultDescription
selectedIndexnumber---Currently selected item (controlled)
scrollAlignmentScrollAlignment'auto'How to position the selected item in view

Alignment modes:

  • 'auto' --- Minimal scrolling to bring item into view
  • 'top' --- Align item to top of viewport
  • 'bottom' --- Align item to bottom of viewport
  • 'center' --- Center item in viewport

Keyboard Navigation

Enable built-in keyboard controls on any ScrollView or ScrollList with the keyboard prop:

<ScrollView keyboard height={20}>
    {items.map((item) => (
        <Text key={item.id}>{item.label}</Text>
    ))}
</ScrollView>

Default key bindings:

KeyAction
Arrow Up/DownScroll by 1 line
Page Up/DownScroll by viewport height
Home/EndJump to top/bottom
Ctrl+U/DHalf-page scroll

Vim bindings (add vimBindings prop):

<ScrollView keyboard vimBindings height={20}>
    ...
</ScrollView>
KeyAction
j/kScroll down/up
GJump to bottom
gJump to top
u/dHalf-page up/down

The useScrollInput hook is also available for custom scroll containers:

import { useScrollInput } from "@visulima/tui";

useScrollInput({
    scrollBy: (delta) => ref.current?.scrollBy(delta),
    scrollToTop: () => ref.current?.scrollToTop(),
    scrollToBottom: () => ref.current?.scrollToBottom(),
    viewportHeight: 20,
    vimBindings: true,
});

Auto-Follow (Log Tailing)

Keep a ScrollView pinned to the bottom as content grows — useful for log viewers:

<ScrollView followOutput height={20}>
    {logLines.map((line, i) => (
        <Text key={i}>{line}</Text>
    ))}
</ScrollView>

When followOutput is enabled:

  • If the user is at/near the bottom, new content auto-scrolls to stay at the bottom
  • If the user scrolls up, auto-follow pauses
  • Scrolling back to the bottom resumes auto-follow

Adjust followThreshold (default 3) to control how many lines from the bottom still counts as "at bottom".

Infinite Scroll Callbacks

Detect when the user scrolls near the edges for lazy loading:

<ScrollView height={20} onReachEnd={() => loadMoreItems()} onReachStart={() => loadPreviousItems()} reachThreshold={5}>
    {items.map((item) => (
        <Text key={item.id}>{item.label}</Text>
    ))}
</ScrollView>

Callbacks fire once per entry into the threshold zone and don't re-fire until the user scrolls away and back.

Linked Scroll

Synchronize scroll position across multiple ScrollView instances (e.g., diff viewers, multi-pane layouts):

import { createLinkedScrollGroup, ScrollView, Text } from "@visulima/tui";

const group = createLinkedScrollGroup();

const LeftPane = () => {
    const { ref, onScroll } = group.useLinkedScroll();

    return (
        <ScrollView ref={ref} onScroll={onScroll} height={20}>
            {leftItems.map((item) => (
                <Text key={item.id}>{item.label}</Text>
            ))}
        </ScrollView>
    );
};

const RightPane = () => {
    const { ref, onScroll } = group.useLinkedScroll();

    return (
        <ScrollView ref={ref} onScroll={onScroll} height={20}>
            {rightItems.map((item) => (
                <Text key={item.id}>{item.label}</Text>
            ))}
        </ScrollView>
    );
};

Scrolling one pane automatically scrolls all others in the same group.

Virtualization

For large lists, enable virtualization to only render items visible in the viewport:

<ScrollView virtualize overscan={5} height={20}>
    {thousandsOfItems.map((item) => (
        <Text key={item.id}>{item.label}</Text>
    ))}
</ScrollView>
  • Items are measured on first render, then only visible items (plus overscan buffer) are rendered
  • Spacer elements maintain correct scroll position and content height
  • Works with all other ScrollView features (keyboard, followOutput, etc.)
Support

Contribute to our work and keep us going

Community is the heart of open source. The success of our packages wouldn't be possible without the incredible contributions of users, testers, and developers who collaborate with us every day.Want to get involved? Here are some tips on how you can make a meaningful impact on our open source projects.

Ready to help us out?

Be sure to check out the package's contribution guidelines first. They'll walk you through the process on how to properly submit an issue or pull request to our repositories.

Submit a pull request

Found something to improve? Fork the repo, make your changes, and open a PR. We review every contribution and provide feedback to help you get merged.

Good first issues

Simple issues suited for people new to open source development, and often a good place to start working on a package.
View good first issues