npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ink-scrollable-box

v1.1.0

Published

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

Readme

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 >= 4 and react >= 18 as peer dependencies.

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

| Prop | Type | Default | Description | |------|------|---------|-------------| | height | number | required | Viewport height in terminal lines | | width | number | -- | Viewport width in terminal columns. When set, fixes the container width. | | lines | string[] | -- | String content (mutually exclusive with children) | | children | ReactNode | -- | React node content (mutually exclusive with lines) | | followOutput | boolean | false | Auto-scroll to bottom when content grows | | scrollStep | number | 1 | Lines per arrow key / j/k press | | border | boolean | false | Render a rounded border around the viewport | | overscan | number | 0 | Extra items to pre-render above/below viewport | | measureChildren | boolean | false | Measure actual heights of multi-line children (O(n) render) | | debug | boolean | false | Disable overflow clipping for layout debugging |

Scrollbar Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | showScrollbar | boolean | true | Show 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 | | showIndicators | boolean | true | Show overflow indicators above/below content | | scrollbarStyle | 'block' \| 'line' \| 'thick' \| 'dots' | 'block' | Built-in scrollbar visual style | | scrollbarCharacter | string | per style | Override the scrollbar thumb character | | trackCharacter | string | per style | Override the scrollbar track character | | upIndicator | string | | Top overflow indicator character | | downIndicator | string | | Bottom overflow indicator character | | scrollbarColor | string | -- | Thumb color when focused | | scrollbarDimColor | string | -- | Thumb color when unfocused | | trackColor | string | -- | Track color |

Focus and Keyboard Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | focusable | boolean | true | Participate in Tab focus cycle | | autoFocus | boolean | false | Auto-focus on mount | | id | string | -- | Focus ID for programmatic focus / multi-pane | | enableVimBindings | boolean | true | Enable vim-style keys (j/k/g/G/u/d) |

Border Styling Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | borderColor | string | 'blue' | Border color when focused | | borderDimColor | string | 'gray' | Border color when unfocused |

Callback Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | 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 is within reachThreshold of the bottom | | onReachStart | () => void | -- | Called when scroll is within reachThreshold of the top | | reachThreshold | number | 5 | Lines from edge to trigger onReachEnd / onReachStart |

Controlled Mode Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | offset | number | -- | 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:

| Option | Type | Default | Description | |--------|------|---------|-------------| | contentHeight | number | required | Total number of content rows | | viewportHeight | number | required | Visible row count | | scrollStep | number | 1 | Rows per scroll action | | followOutput | boolean | false | Auto-scroll when content grows | | initialOffset | number | 0 | Starting scroll position | | controlledOffset | number | -- | External controlled offset (overrides internal state) | | onOffsetChange | (offset: number) => void | -- | Called when offset would change (for controlled mode) |

Returns (UseScrollableResult = ScrollState & ScrollActions):

| Field | Type | Description | |-------|------|-------------| | offset | number | Current scroll offset (first visible row index) | | contentHeight | number | Total content rows | | viewportHeight | number | Visible rows | | canScrollUp | boolean | True when not at top | | canScrollDown | boolean | True when not at bottom | | isAtTop | boolean | True when at first row | | isAtBottom | boolean | True when at last row | | percentage | number | Scroll position 0--100 | | scrollUp() | () => void | Scroll up by scrollStep | | scrollDown() | () => void | Scroll down by scrollStep | | scrollTo(n) | (n: number) => void | Jump to absolute offset | | scrollToTop() | () => void | Jump to top | | scrollToBottom() | () => void | Jump to bottom | | pageUp() | () => void | Scroll up one full page | | pageDown() | () => void | Scroll down one full page | | halfPageUp() | () => void | Scroll up half a page | | halfPageDown() | () => void | Scroll down half a page |


useScrollableInput(options)

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

Options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | scroll | UseScrollableResult | required | The scroll state object from useScrollable | | focusable | boolean | true | Participate in Tab focus cycle | | autoFocus | boolean | false | Auto-focus on mount | | id | string | -- | Focus ID for programmatic focus | | enableVimBindings | boolean | true | Enable vim-style keys |

Returns:

| Field | Type | Description | |-------|------|-------------| | isFocused | boolean | Whether the component currently has focus |


useLinkedScroll(options?)

Synchronize scroll position across multiple ScrollableBox instances.

Options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | initialOffset | number | 0 | Starting offset |

Returns:

| Field | Type | Description | |-------|------|-------------| | offset | number | Shared scroll offset | | onOffsetChange | (offset: number) => void | Spread onto each ScrollableBox |


<Scrollbar />

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

| Prop | Type | Default | Description | |------|------|---------|-------------| | offset | number | required | Current scroll offset | | contentHeight | number | required | Total content rows | | viewportHeight | number | required | Visible rows | | isFocused | boolean | required | Whether the parent is focused (affects color) | | scrollbarStyle | 'block' \| 'line' \| 'thick' \| 'dots' | 'block' | Built-in visual style | | thumbCharacter | string | per style | Override thumb character | | trackCharacter | string | per style | Override track character | | thumbColor | string | -- | Thumb color when focused | | thumbDimColor | string | -- | Thumb color when unfocused | | trackColor | string | -- | Track color |


ScrollableBoxRef

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

| Method | Description | |--------|-------------| | 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

| Key | Action | |-----|--------| | Up / k | Scroll up | | Down / j | Scroll down | | g | Jump to top | | G (Shift+G) | Jump to bottom | | Page Up / u | Scroll up one page | | Page Down / d | Scroll down one page | | Ctrl+U | Scroll up half page | | Ctrl+D | Scroll down half page | | Home | Jump to top | | End | Jump to bottom | | Tab | Move 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:

| Style | Thumb | Track | |-------|-------|-------| | 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

| Feature | ink-scrollable-box | ink-scroll-view | ink-scrollbar | |---------|--------------------|-----------------|---------------| | Keyboard navigation | vim + arrows + Page + Home/End | -- | -- | | Focus management | Tab cycling + autoFocus | -- | -- | | followOutput | yes | -- | -- | | Dual content modes | lines + children | children only | N/A | | Scrollbar styles | 4 built-in + custom | -- | partial | | Controlled mode | yes | -- | -- | | Linked scroll | useLinkedScroll hook | -- | -- | | Infinite scroll | onReachEnd / onReachStart | -- | -- | | Standalone hooks | useScrollable, useScrollableInput | -- | -- | | Ref API | scrollToIndex, getItemHeight, etc. | -- | -- | | TypeScript | first-class | yes | yes | | Dependencies | 0 (peer only) | 0 (peer only) | 0 |

Contributing

See CONTRIBUTING.md.

License

MIT