Sign In

ink-tree-view

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ink-tree-view

A tree view component for Ink with expand/collapse, keyboard navigation, and selection

latest
Source
npmnpm
Version
0.4.0
Version published
Weekly downloads
12
-33.33%
Maintainers
1
Weekly downloads
 
Created
Source

ink-tree-view

npm version CI license npm downloads

ink-tree-view demo

A tree view component for Ink (React for CLIs). Display hierarchical data with expand/collapse, keyboard navigation, selection modes, custom rendering, virtual scrolling, and async lazy loading.

Features

  • Hierarchical data display with expand/collapse
  • Full keyboard navigation (arrows, Home/End, PageUp/PageDown, Enter, Space)
  • Controlled or uncontrolled expansion, selection, and focus
  • Selection modes: none, single, and multiple (with checkboxes)
  • Custom node rendering via renderNode prop
  • Virtual scrolling for large trees (visibleNodeCount)
  • Async/lazy-loaded children via loadChildren + isParent
  • Error handling for failed async loads via onLoadError
  • Headless hooks (useTreeViewState, useTreeView) for full control
  • TypeScript-first with complete type exports

Install

npm install ink-tree-view

Peer dependencies: ink (>=6.0.0), react (>=19.0.0). Requires Node.js >=20.

Quick Start

import {render} from 'ink';
import {TreeView} from 'ink-tree-view';

const data = [
  {
    id: 'src',
    label: 'src',
    children: [
      {id: 'index', label: 'index.ts'},
      {
        id: 'components',
        label: 'components',
        children: [
          {id: 'button', label: 'button.tsx'},
          {id: 'input', label: 'input.tsx'},
        ],
      },
    ],
  },
  {id: 'readme', label: 'README.md'},
  {id: 'package', label: 'package.json'},
];

render(<TreeView data={data} />);

Use arrow keys to navigate, Right to expand, Left to collapse, and Enter to toggle.

Data Model

Tree data is an array of TreeNode<T> objects. Each node must have a unique id across the entire tree.

type TreeNode<T = Record<string, unknown>> = {
  /** Unique identifier. Must be unique across the entire tree. */
  id: string;
  /** Display label used by the default renderer. */
  label: string;
  /** Arbitrary user data attached to this node. */
  data?: T;
  /** Child nodes. Undefined or empty array means leaf node. */
  children?: Array<TreeNode<T>>;
  /** Mark as a parent whose children will be loaded via loadChildren. */
  isParent?: boolean;
};

Example with custom data

type FileInfo = {size: number; modified: string};

const data: TreeNode<FileInfo>[] = [
  {
    id: 'doc',
    label: 'document.pdf',
    data: {size: 1024, modified: '2025-01-15'},
  },
];

Props

PropTypeDefaultDescription
dataTreeNode<T>[]requiredArray of root-level tree nodes.
selectionMode'none' | 'single' | 'multiple''none'Selection behavior. 'single' allows one selected node; 'multiple' shows checkboxes.
defaultExpandedReadonlySet<string> | 'all'undefinedNode IDs expanded on mount (uncontrolled), or 'all' to expand everything.
defaultSelectedReadonlySet<string>undefinedNode IDs selected on mount (uncontrolled, ignored in 'none' mode).
expandedReadonlySet<string>undefinedControlled expanded set. When set, the tree is controlled: keys report intent via onExpandChange and this prop is authoritative.
selectedReadonlySet<string>undefinedControlled selected set. When set, selection is controlled via onSelectChange.
focusedIdstringundefinedControlled focused node ID. When set, focus is controlled via onFocusChange.
visibleNodeCountnumberInfinityMax visible rows. Enables virtual scrolling when finite. Recommended for large trees (see Virtual Scrolling).
renderNode(props: TreeNodeRendererProps<T>) => ReactNodeundefinedCustom renderer for each node. Receives {node, state}.
loadChildren(node: TreeNode<T>) => Promise<TreeNode<T>[]>undefinedAsync loader called when expanding an isParent: true node.
onLoadError(nodeId: string, error: Error) => voidundefinedCalled when loadChildren rejects. Loading state is cleared so the user can retry.
onFocusChange(nodeId: string) => voidundefinedCalled when the focused node changes (not on initial mount).
onExpandChange(expandedIds: ReadonlySet<string>) => voidundefinedCalled when the set of expanded nodes changes.
onSelectChange(selectedIds: ReadonlySet<string>) => voidundefinedCalled when the selection changes.
isDisabledbooleanfalseWhen true, all keyboard input is ignored.

Keyboard Shortcuts

KeyAction
Up ArrowMove focus to the previous visible node
Down ArrowMove focus to the next visible node
Right ArrowExpand focused node, or move to first child if already expanded. Triggers async load for isParent nodes.
Left ArrowCollapse focused node, or move to parent if already collapsed
EnterToggle expand/collapse ('none' mode) or select ('single'/'multiple' mode)
SpaceToggle expand/collapse ('none'/'single' mode) or toggle selection ('multiple' mode)
HomeJump to the first node
EndJump to the last node
Page UpMove focus up by one viewport page
Page DownMove focus down by one viewport page

Custom Rendering

Use the renderNode prop to completely control how each node looks.

import {Box, Text} from 'ink';
import {TreeView, type TreeNodeRendererProps} from 'ink-tree-view';

type FileData = {size: number};

function CustomNode({node, state}: TreeNodeRendererProps<FileData>) {
  const prefix = state.hasChildren
    ? state.isExpanded ? 'v ' : '> '
    : '  ';

  return (
    <Box>
      <Text dimColor={!state.isFocused}>
        {'  '.repeat(state.depth)}
        {prefix}
        {node.label}
      </Text>
      {node.data && (
        <Text color="gray"> ({node.data.size} bytes)</Text>
      )}
      {state.isSelected && <Text color="green"> [selected]</Text>}
    </Box>
  );
}

render(
  <TreeView<FileData>
    data={data}
    selectionMode="single"
    renderNode={CustomNode}
  />
);

TreeNodeRendererProps<T> includes:

  • node -- the TreeNode<T> data
  • state -- a TreeNodeState with: isFocused, isExpanded, isSelected, depth, hasChildren, isLoading

Selection Modes

No selection (default)

<TreeView data={data} />

Enter and Space toggle expand/collapse.

Single selection

<TreeView
  data={data}
  selectionMode="single"
  onSelectChange={(selectedIds) => {
    const selected = [...selectedIds][0];
    console.log('Selected:', selected);
  }}
/>

Enter selects the focused node. Only one node can be selected at a time.

Multiple selection

<TreeView
  data={data}
  selectionMode="multiple"
  defaultSelected={new Set(['node-1', 'node-3'])}
  onSelectChange={(selectedIds) => {
    console.log('Selected:', [...selectedIds]);
  }}
/>

Enter and Space toggle selection on the focused node. Checkboxes appear next to each node.

Controlled Mode

By default the tree is uncontrolled: it manages its own expansion, selection, and focus, seeded by defaultExpanded / defaultSelected. Pass the controlled counterparts (expanded, selected, focusedId) to take ownership of that state instead. This follows the standard React controlled/uncontrolled pattern: when a controlled prop is present, keypresses report intent through the matching callback (onExpandChange, onSelectChange, onFocusChange) and the prop stays authoritative until you update it.

import {useState} from 'react';
import {TreeView} from 'ink-tree-view';

function ControlledTree({data}) {
  const [expanded, setExpanded] = useState(new Set<string>());
  const [selected, setSelected] = useState(new Set<string>());

  return (
    <TreeView
      data={data}
      selectionMode="multiple"
      expanded={expanded}
      selected={selected}
      onExpandChange={ids => setExpanded(new Set(ids))}
      onSelectChange={ids => setSelected(new Set(ids))}
    />
  );
}

Mix and match: control only selected while leaving expansion uncontrolled, or vice versa. Any prop you omit keeps its uncontrolled behavior.

Virtual Scrolling

For large trees, set visibleNodeCount to limit the number of visible rows. The viewport scrolls to keep the focused node in view, and scroll indicators appear when content extends beyond the viewport.

<TreeView
  data={largeTree}
  defaultExpanded="all"
  visibleNodeCount={15}
/>

Performance: visibleNodeCount defaults to Infinity, which renders every visible node. Individual rows are memoized so a keypress only re-renders the rows whose state actually changed, but for large, fully-expanded trees you should still set a finite visibleNodeCount so only a windowed slice is rendered at all.

Async Children

Use loadChildren to lazily load children when a node is first expanded. Mark on-demand nodes with isParent: true. A loading indicator is shown while the request is in progress.

async function fetchChildren(node) {
  const response = await fetch(`/api/tree/${node.id}/children`);
  return response.json();
}

const data = [
  {id: 'root', label: 'Root', isParent: true},
  {id: 'leaf', label: 'Leaf'},
];

render(
  <TreeView
    data={data}
    loadChildren={fetchChildren}
    onLoadError={(nodeId, error) => {
      console.error(`Failed to load children for ${nodeId}:`, error.message);
    }}
  />
);

When loadChildren rejects, onLoadError fires and the loading state is cleared so the user can retry by pressing Right Arrow again.

Hooks API

For headless/custom usage, two hooks are exported directly.

useTreeViewState<T>(props)

Manages all tree state: focus, expansion, selection, viewport scrolling, and loading.

import {useTreeViewState} from 'ink-tree-view';

const state = useTreeViewState({
  data,
  selectionMode: 'multiple',
  defaultExpanded: new Set(['root']),
  visibleNodeCount: 10,
  onFocusChange: (id) => { /* ... */ },
  onExpandChange: (ids) => { /* ... */ },
  onSelectChange: (ids) => { /* ... */ },
});

Returned state:

PropertyTypeDescription
focusedIdstring | undefinedCurrently focused node ID
expandedIdsReadonlySet<string>Set of expanded node IDs
selectedIdsReadonlySet<string>Set of selected node IDs
viewportNodesArray<{node, state}>Nodes in current viewport
visibleCountnumberTotal visible node count
hasScrollUpbooleanNodes exist above the viewport
hasScrollDownbooleanNodes exist below the viewport
loadingIdsReadonlySet<string>Currently loading node IDs
nodeMapTreeNodeMap<T>Underlying data structure

Actions:

MethodDescription
focusNext()Move focus down
focusPrevious()Move focus up
focusFirst()Jump to first node
focusLast()Jump to last node
focusPageDown()Move focus down by one viewport page
focusPageUp()Move focus up by one viewport page
expand()Expand focused node
expandNode(id)Expand a specific node
collapse()Collapse focused node
collapseNode(id)Collapse a specific node
toggleExpanded()Toggle expand/collapse on focused node
expandAll()Expand all nodes
collapseAll()Collapse all nodes
select()Select/deselect focused node
focusParent()Move focus to parent
focusFirstChild()Move focus to first child
setLoading(id, bool)Mark a node as loading
setChildrenError(id)Clear loading state after failure
insertChildren(parentId, children)Insert children under a parent

useTreeView<T>(props)

Wires keyboard input to a TreeViewState instance. Call this after useTreeViewState to enable keyboard navigation.

import {Box, Text} from 'ink';
import {useTreeViewState, useTreeView} from 'ink-tree-view';

function MyTree({data}) {
  const state = useTreeViewState({data});

  useTreeView({
    state,
    selectionMode: 'none',
    loadChildren: async (node) => {
      // fetch children...
    },
  });

  return (
    <Box flexDirection="column">
      {state.viewportNodes.map(({node, state: ns}) => (
        <Text key={node.id} bold={ns.isFocused}>
          {'  '.repeat(ns.depth)}{node.label}
        </Text>
      ))}
    </Box>
  );
}

TypeScript

All types are exported from the package entry point:

import type {
  TreeNode,
  TreeNodeState,
  SelectionMode,
  AsyncChildrenFn,
  TreeViewProps,
  TreeNodeRendererProps,
  TreeViewState,
  UseTreeViewStateProps,
  UseTreeViewProps,
  TreeViewTheme,
  FlatNode,
} from 'ink-tree-view';

import {
  TreeView,
  useTreeViewState,
  useTreeView,
  treeViewTheme,
  TreeNodeMap,
} from 'ink-tree-view';

Contributing

Contributions are welcome. Please open an issue to discuss your idea before submitting a PR.

git clone https://github.com/costajohnt/ink-tree-view.git
cd ink-tree-view
npm install
npm test

Run npm run build to compile and npm run typecheck to verify types.

Changelog

See GitHub Releases.

License

MIT -- Copyright (c) 2024-2026 John Costa

Keywords

ink

FAQs

Package last updated on 25 Jul 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts