Sign In

ink-scrollable-box

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ink-scrollable-box

Scrollable container component for Ink with keyboard navigation, vim bindings, and auto-follow

latest
Source
npmnpm
Version
1.1.2
Version published
Maintainers
1
Created
Source

ink-scrollable-box

npm version npm downloads CI License: MIT

Scrollable container component for Ink with keyboard navigation, vim bindings, scrollbar styles, and auto-follow.

hero demo

Install

npm install ink-scrollable-box
yarn add ink-scrollable-box
pnpm add ink-scrollable-box

Requires ink >= 6 and react >= 19 as peer dependencies. (The component uses Ink 6's useIsScreenReaderEnabled hook for accessibility announcements.)

Quick Start

import {render} from 'ink';
import {ScrollableBox} from 'ink-scrollable-box';

const lines = Array.from({length: 100}, (_, i) => `Item ${i + 1}`);

render(<ScrollableBox height={15} lines={lines} autoFocus border />);

Features

  • Two content modes: lines (string array, virtualized) and children (React nodes)
  • Keyboard navigation with arrow keys, Page Up/Down, Home/End
  • Vim bindings (j/k/g/G/u/d, Ctrl+U/D)
  • Auto-follow output (log tailing) with manual scroll-to-pause
  • Proportional scrollbar with 4 built-in styles (block, line, thick, dots)
  • Half-line precision scrollbar rendering for block style
  • Tab-based focus management across multiple panes
  • autoFocus for immediate keyboard control on mount
  • Controlled mode via offset / onOffsetChange
  • Ref API for programmatic scrolling (scrollTo, scrollToIndex, etc.)
  • Linked scroll via useLinkedScroll hook
  • Infinite scroll callbacks (onReachEnd, onReachStart)
  • Variable-height child measurement (measureChildren)
  • Overscan for pre-rendering items above/below viewport
  • Fully customizable scrollbar characters, colors, and border styling
  • Standalone useScrollable and useScrollableInput hooks
  • Zero runtime dependencies (peer deps only)
  • TypeScript-first with full type exports

Examples

Lines Mode (basic)

import {render, Box, Text} from 'ink';
import {ScrollableBox} from 'ink-scrollable-box';

const lines = Array.from({length: 100}, (_, i) => `Item ${i + 1}`);

render(
  <Box flexDirection="column">
    <Text bold>100 items -- j/k/g/G to navigate</Text>
    <ScrollableBox height={15} lines={lines} autoFocus border />
  </Box>
);

Children Mode (styled React nodes)

children mode demo

import {render, Text} from 'ink';
import {ScrollableBox} from 'ink-scrollable-box';

const items = [
  {color: 'green', text: 'Build succeeded'},
  {color: 'red', text: 'Test: auth.test.ts failed'},
  {color: 'yellow', text: 'Coverage: 89%'},
];

render(
  <ScrollableBox height={6} autoFocus border>
    {items.map((item, i) => (
      <Text key={i} color={item.color}>{item.text}</Text>
    ))}
  </ScrollableBox>
);

Log Follower (followOutput)

log follower demo

import {useState, useEffect} from 'react';
import {render, Text} from 'ink';
import {ScrollableBox} from 'ink-scrollable-box';

function App() {
  const [logs, setLogs] = useState<string[]>([]);
  useEffect(() => {
    const id = setInterval(() => {
      setLogs(prev => [...prev, `[${new Date().toISOString()}] Entry #${prev.length + 1}`]);
    }, 200);
    return () => clearInterval(id);
  }, []);

  return <ScrollableBox height={15} lines={logs} followOutput autoFocus border />;
}

render(<App />);

Multi-Pane (Tab focus)

multi-pane demo

import {render, Box, Text} from 'ink';
import {ScrollableBox} from 'ink-scrollable-box';

const left = Array.from({length: 30}, (_, i) => `Left-${i + 1}`);
const right = Array.from({length: 50}, (_, i) => `Right-${i + 1}`);

render(
  <Box flexDirection="row" gap={2}>
    <ScrollableBox height={10} lines={left} border id="left" autoFocus />
    <ScrollableBox height={10} lines={right} border id="right" />
  </Box>
);

Controlled Mode

import {useState} from 'react';
import {render} from 'ink';
import {ScrollableBox} from 'ink-scrollable-box';

const lines = Array.from({length: 100}, (_, i) => `Item ${i + 1}`);

function App() {
  const [offset, setOffset] = useState(0);
  return <ScrollableBox height={10} lines={lines} offset={offset} onOffsetChange={setOffset} autoFocus />;
}

render(<App />);

Ref API (programmatic scrolling)

import {useRef} from 'react';
import {render, Box, Text} from 'ink';
import {ScrollableBox, ScrollableBoxRef} from 'ink-scrollable-box';

const lines = Array.from({length: 100}, (_, i) => `Item ${i + 1}`);

function App() {
  const ref = useRef<ScrollableBoxRef>(null);
  // Call ref.current.scrollToIndex(50, {align: 'center'}) to jump to item 50
  return <ScrollableBox ref={ref} height={10} lines={lines} autoFocus border />;
}

render(<App />);

Linked Scroll (useLinkedScroll)

Synchronize scroll position across multiple panes:

import {render, Box} from 'ink';
import {ScrollableBox, useLinkedScroll} from 'ink-scrollable-box';

const left = Array.from({length: 100}, (_, i) => `Left-${i + 1}`);
const right = Array.from({length: 100}, (_, i) => `Right-${i + 1}`);

function App() {
  const linked = useLinkedScroll();
  return (
    <Box flexDirection="row" gap={2}>
      <ScrollableBox height={10} lines={left} offset={linked.offset} onOffsetChange={linked.onOffsetChange} autoFocus border />
      <ScrollableBox height={10} lines={right} offset={linked.offset} onOffsetChange={linked.onOffsetChange} border />
    </Box>
  );
}

render(<App />);

Infinite Scroll (onReachEnd)

import {useState, useCallback} from 'react';
import {render} from 'ink';
import {ScrollableBox} from 'ink-scrollable-box';

function App() {
  const [lines, setLines] = useState(Array.from({length: 50}, (_, i) => `Item ${i + 1}`));
  const loadMore = useCallback(() => {
    setLines(prev => [...prev, ...Array.from({length: 20}, (_, i) => `Item ${prev.length + i + 1}`)]);
  }, []);

  return <ScrollableBox height={15} lines={lines} onReachEnd={loadMore} reachThreshold={5} autoFocus border />;
}

render(<App />);

API Reference

<ScrollableBox />

Core Props

PropTypeDefaultDescription
heightnumberrequiredViewport height in terminal lines
widthnumber--Viewport width in terminal columns. When set, fixes the container width.
linesstring[]--String content (mutually exclusive with children)
childrenReactNode--React node content (mutually exclusive with lines)
followOutputbooleanfalseAuto-scroll to bottom when content grows
initialOffsetnumber0Starting scroll offset in uncontrolled mode (ignored when offset is set). Applied once against the content height known at mount; see note below
scrollStepnumber1Lines per arrow key / j/k press
borderbooleanfalseRender a rounded border around the viewport
overscannumber0Extra items to pre-render above/below viewport
measureChildrenbooleanfalseMeasure actual heights of multi-line children (O(n) render)
debugbooleanfalseDisable overflow clipping for layout debugging

initialOffset caveat: it is applied once at mount against the content height known at that moment and rounded/clamped to a valid offset. It does not track content that loads or is measured after mount (async data or measureChildren, where heights are unknown at mount), so it cannot pin to a not-yet-known bottom. To keep a growing log pinned to the bottom, use followOutput.

Scrollbar Props

PropTypeDefaultDescription
showScrollbarbooleantrueShow the proportional scrollbar
scrollbarPosition'inside' | 'outside''inside'inside renders the scrollbar alongside content within the border; outside renders it to the right of the border, saving 1 column of content width
showIndicatorsbooleantrueShow overflow indicators above/below content
scrollbarStyle'block' | 'line' | 'thick' | 'dots''block'Built-in scrollbar visual style
scrollbarCharacterstringper styleOverride the scrollbar thumb character
trackCharacterstringper styleOverride the scrollbar track character
upIndicatorstringTop overflow indicator character
downIndicatorstringBottom overflow indicator character
scrollbarColorstring--Thumb color when focused
scrollbarDimColorstring--Thumb color when unfocused
trackColorstring--Track color

Focus and Keyboard Props

PropTypeDefaultDescription
focusablebooleantrueParticipate in Tab focus cycle
autoFocusbooleanfalseAuto-focus on mount
idstring--Focus ID for programmatic focus / multi-pane
enableVimBindingsbooleantrueEnable vim-style keys (j/k/g/G/u/d)

Border Styling Props

PropTypeDefaultDescription
borderColorstring'blue'Border color when focused
borderDimColorstring'gray'Border color when unfocused

Callback Props

PropTypeDefaultDescription
onScroll(state: ScrollState) => void--Called on every scroll position change
onFocus() => void--Called when the component gains focus
onBlur() => void--Called when the component loses focus
onContentHeightChange(height: number, previousHeight: number) => void--Called when total content height changes
onViewportSizeChange(height: number, previousHeight: number) => void--Called when viewport height changes
onItemHeightChange(index: number, height: number, previousHeight: number) => void--Called when a measured child's height changes (requires measureChildren)
onReachEnd() => void--Called when scroll enters within reachThreshold of the bottom (edge-triggered; see note)
onReachStart() => void--Called when scroll enters within reachThreshold of the top (edge-triggered; see note)
reachThresholdnumber5Lines from edge to trigger onReachEnd / onReachStart

onReachEnd / onReachStart are edge-triggered: each fires once when the offset crosses into its threshold zone and re-arms only after the offset leaves the zone. They do not fire on mount. In particular, when followOutput keeps the viewport pinned to the bottom across a continuous append stream, the offset never leaves the end zone, so onReachEnd fires once and does not re-fire while pinned.

Controlled Mode Props

PropTypeDefaultDescription
offsetnumber--Controlled scroll offset (makes the component controlled)
onOffsetChange(offset: number) => void--Called when offset changes in controlled mode

useScrollable(options)

Standalone scroll state hook. Use this to build a fully custom scroll UI.

Options:

OptionTypeDefaultDescription
contentHeightnumberrequiredTotal number of content rows
viewportHeightnumberrequiredVisible row count
scrollStepnumber1Rows per scroll action
followOutputbooleanfalseAuto-scroll when content grows
initialOffsetnumber0Starting scroll position
controlledOffsetnumber--External controlled offset (overrides internal state)
onOffsetChange(offset: number) => void--Called when offset would change (for controlled mode)

Returns (UseScrollableResult = ScrollState & ScrollActions):

FieldTypeDescription
offsetnumberCurrent scroll offset (first visible row index)
contentHeightnumberTotal content rows
viewportHeightnumberVisible rows
canScrollUpbooleanTrue when not at top
canScrollDownbooleanTrue when not at bottom
isAtTopbooleanTrue when at first row
isAtBottombooleanTrue when at last row
percentagenumberScroll position 0--100
scrollUp()() => voidScroll up by scrollStep
scrollDown()() => voidScroll down by scrollStep
scrollTo(n)(n: number) => voidJump to absolute offset
scrollToTop()() => voidJump to top
scrollToBottom()() => voidJump to bottom
pageUp()() => voidScroll up one full page
pageDown()() => voidScroll down one full page
halfPageUp()() => voidScroll up half a page
halfPageDown()() => voidScroll down half a page

useScrollableInput(options)

Wires Ink's useInput to a UseScrollableResult. Used internally by ScrollableBox but exported for custom UIs.

Options:

OptionTypeDefaultDescription
scrollUseScrollableResultrequiredThe scroll state object from useScrollable
focusablebooleantrueParticipate in Tab focus cycle
autoFocusbooleanfalseAuto-focus on mount
idstring--Focus ID for programmatic focus
enableVimBindingsbooleantrueEnable vim-style keys

Returns:

FieldTypeDescription
isFocusedbooleanWhether the component currently has focus

useLinkedScroll(options?)

Synchronize scroll position across multiple ScrollableBox instances.

Options:

OptionTypeDefaultDescription
initialOffsetnumber0Starting offset

Returns:

FieldTypeDescription
offsetnumberShared scroll offset
onOffsetChange(offset: number) => voidSpread onto each ScrollableBox

<Scrollbar />

Standalone scrollbar component. Used internally but exported for custom layouts.

PropTypeDefaultDescription
offsetnumberrequiredCurrent scroll offset
contentHeightnumberrequiredTotal content rows
viewportHeightnumberrequiredVisible rows
isFocusedbooleanrequiredWhether the parent is focused (affects color)
scrollbarStyle'block' | 'line' | 'thick' | 'dots''block'Built-in visual style
thumbCharacterstringper styleOverride thumb character
trackCharacterstringper styleOverride track character
thumbColorstring--Thumb color when focused
thumbDimColorstring--Thumb color when unfocused
trackColorstring--Track color

ScrollableBoxRef

All methods available on a ref obtained via useRef<ScrollableBoxRef>().

MethodDescription
scrollTo(offset)Jump to a specific offset (clamped to valid range)
scrollBy(delta)Scroll by a relative delta (positive = down, negative = up)
scrollToTop()Jump to the top
scrollToBottom()Jump to the bottom
scrollUp()Scroll up by scrollStep lines
scrollDown()Scroll down by scrollStep lines
pageUp()Scroll up by one viewport height
pageDown()Scroll down by one viewport height
halfPageUp()Scroll up by half viewport height
halfPageDown()Scroll down by half viewport height
scrollToIndex(index, options?)Scroll to a specific item index with optional {align: 'start' | 'center' | 'end' | 'auto'}
getScrollState()Returns the current ScrollState object
getBottomOffset()Returns the maximum scroll offset (contentHeight - viewportHeight)
getItemHeight(index)Get the height of a child in terminal lines (returns 1 in non-measure mode)
getItemPosition(index)Get {top, height} of a child, or undefined if out of range
remeasureItem(index)Force re-measurement of a child (requires measureChildren)

Keyboard Shortcuts

KeyAction
Up / kScroll up
Down / jScroll down
gJump to top
G (Shift+G)Jump to bottom
Page Up / uScroll up one page
Page Down / dScroll down one page
Ctrl+UScroll up half page
Ctrl+DScroll down half page
HomeJump to top
EndJump to bottom
TabMove focus to next pane

Vim bindings (j, k, g, G, u, d) can be disabled with enableVimBindings={false}. Arrow keys, Page Up/Down, Home/End, and Ctrl+U/D are always active when focused.

Scrollbar Styles

Set scrollbarStyle to change the built-in look:

StyleThumbTrack
block (default)
line (space)
thick
dots·

The block style uses half-line precision rendering (▀/▄ characters) for smoother positioning. Override individual characters with scrollbarCharacter and trackCharacter.

How It Works

Lines mode slices the content array to render only visible rows (lines.slice(offset, offset + height)). Render cost is O(viewport) regardless of content size -- 100,000 lines renders the same as 100.

Children mode renders only the visible subset of React children. When measureChildren is enabled, all children are rendered and measured for accurate scroll math with multi-line content (O(n) rendering).

The useScrollable hook manages offset state and exposes scroll actions. useScrollableInput wires Ink's useInput to those actions. ScrollableBox composes both internally.

Comparison with Alternatives

Featureink-scrollable-boxink-scroll-viewink-scrollbar
Keyboard navigationvim + arrows + Page + Home/End----
Focus managementTab cycling + autoFocus----
followOutputyes----
Dual content modeslines + childrenchildren onlyN/A
Scrollbar styles4 built-in + custom--partial
Controlled modeyes----
Linked scrolluseLinkedScroll hook----
Infinite scrollonReachEnd / onReachStart----
Standalone hooksuseScrollable, useScrollableInput----
Ref APIscrollToIndex, getItemHeight, etc.----
TypeScriptfirst-classyesyes
Dependencies0 (peer only)0 (peer only)0

Contributing

See CONTRIBUTING.md.

License

MIT

Keywords

ink

FAQs

Package last updated on 25 Jul 2026

Related posts