TreeView
Hierarchical navigator with the full WAI-ARIA tree keyboard model
The TreeView component renders hierarchical data an operator has to browse and pick from — product category trees, org and permission structures, folder-like storage layouts. It keeps the whole hierarchy in one scannable column so a reviewer can see where a record sits before acting on it.
Accordion also opens and closes regions, but each of its panels is independent disclosure for prose. TreeView is a single navigable structure: one roving tab stop, arrow keys that walk the visible rows, and exactly one selected node reported back through onSelect. Reach for SidebarNav instead when the hierarchy is app navigation rather than data.
Import
import { TreeView } from '@nim-ui/components';Playground
Click a row to select it, click a chevron to expand. Focus a row and try the arrow keys, Home, End, and Enter.
<TreeView label="Catalog" className="max-w-sm" defaultExpandedIds={['apparel']} defaultSelectedId="tees" data={[ { id: 'apparel', label: 'Apparel', badge: 248, children: [ { id: 'tees', label: 'T-shirts', badge: 96 }, { id: 'jackets', label: 'Jackets', badge: 41 }, { id: 'knitwear', label: 'Knitwear', badge: 27 }, ], }, { id: 'home', label: 'Home & Living', badge: 132, children: [ { id: 'kitchen', label: 'Kitchen', children: [ { id: 'cookware', label: 'Cookware', badge: 18 }, { id: 'storage', label: 'Storage', badge: 12 }, ], }, { id: 'bedding', label: 'Bedding', badge: 34 }, ], }, { id: 'archive', label: 'Archived categories', disabled: true }, ]} />
Category picker
The default case: a branch carries a count, a leaf is selectable, and selection uses the muted steel treatment reserved for focus and selection.
- Apparel248
- T-shirts96
- Jackets41
- Home & Living132
Code
<TreeView
label="Catalog"
defaultExpandedIds={['apparel']}
defaultSelectedId="tees"
data={[
{
id: 'apparel',
label: 'Apparel',
badge: 248,
children: [
{ id: 'tees', label: 'T-shirts', badge: 96 },
{ id: 'jackets', label: 'Jackets', badge: 41 },
],
},
{ id: 'home', label: 'Home & Living', badge: 132, children: [{ id: 'bedding', label: 'Bedding', badge: 34 }] },
]}
/>Permission tree with disabled nodes
Disabled nodes stay visible for context but cannot be selected and are skipped by arrow navigation, so a reviewer never lands on a scope they are not allowed to grant.
- Orders
- Read orders
- Issue refunds
- Finance
- Read payouts
- Export ledger
Code
<TreeView
label="Permission scopes"
defaultExpandedIds={['orders', 'finance']}
data={[
{
id: 'orders',
label: 'Orders',
children: [
{ id: 'orders.read', label: 'Read orders' },
{ id: 'orders.refund', label: 'Issue refunds' },
],
},
{
id: 'finance',
label: 'Finance',
children: [
{ id: 'finance.read', label: 'Read payouts' },
{ id: 'finance.export', label: 'Export ledger', disabled: true },
],
},
]}
/>Compact density
size="sm" tightens the rows for dense side panels and inspector rails; md is the default for standalone pickers.
- exports/
- 2026/
- q1-settlements.csv
- q2-settlements.csv
- backups/
Code
<TreeView
label="Storage"
size="sm"
defaultExpandedIds={['exports', 'exports-2026']}
defaultSelectedId="exports-q2"
data={storageNodes}
/>Controlled expansion and selection
Pass expandedIds / onExpandedChange and selectedId / onSelect to own the state — for persisting an operator's open branches, or for expanding a path in response to a search hit.
function CategoryPicker() {
const [expandedIds, setExpandedIds] = useState<string[]>(['apparel']);
const [selectedId, setSelectedId] = useState<string | undefined>();
return (
<TreeView
label="Catalog"
data={categories}
expandedIds={expandedIds}
onExpandedChange={setExpandedIds}
selectedId={selectedId}
onSelect={(id, node) => {
setSelectedId(id);
console.log('picked', node.label);
}}
/>
);
}Props
| Name | Type | Default | Description |
|---|---|---|---|
data* | TreeNode[] | - | Hierarchical node data; each node is { id, label, children?, icon?, badge?, disabled? } |
label* | string | - | Accessible name for the tree |
defaultExpandedIds | string[] | [] | Initially expanded branch ids (uncontrolled) |
expandedIds | string[] | - | Expanded branch ids (controlled) |
onExpandedChange | (ids: string[]) => void | - | Fires whenever the expanded set changes |
defaultSelectedId | string | - | Initially selected node id (uncontrolled) |
selectedId | string | - | Selected node id (controlled) |
onSelect | (id: string, node: TreeNode) => void | - | Fires when a node is selected via click, Enter, or Space |
size | 'sm' | 'md' | 'md' | Row density |
className | string | - | Additional CSS classes to apply |
Accessibility
- Implements the WAI-ARIA tree pattern:
role="tree"witharia-label,role="treeitem"rows carryingaria-level,aria-selected,aria-disabled, andaria-expandedon branches only, plusrole="group"child lists. - Roving tabindex: exactly one row is in the tab order at a time — the selected node, otherwise the first enabled one — so the tree is a single Tab stop.
- Keyboard: Arrow Down / Arrow Up move through visible rows only, skipping collapsed subtrees and disabled nodes.
- Arrow Right expands a collapsed branch; on a second press, once the branch is open, it moves to the first enabled child. Arrow Left collapses an expanded branch, otherwise moves to the parent.
- Home / End jump to the first and last enabled visible rows; Enter and Space select the focused node.
- A keystroke is handled only by the row it originated on, so navigating a nested row never also drives its ancestors.
- The focus ring is drawn on the row itself, not the subtree region, so the focused node stays unambiguous at any depth.
- Each row is named by
aria-labelledbypointing at its own label, so a branch announces just its label rather than the text of everything nested under it.
Best Practices
Do
- Give every node a unique, stable
id— ids drive expansion, selection, and focus - Expand the path to the current record with
defaultExpandedIdsso operators start with context - Use
badgefor counts so a branch communicates volume without being opened - Prefer
size="sm"in side panels where the tree sits beside a detail pane
Don't
- Load a tree with thousands of nodes at once — expand lazily and swap
dataper branch instead - Use TreeView for app navigation; SidebarNav carries
aria-currentand the ink active treatment - Disable a branch to hide it — filter it out of
dataif the operator should never see it - Nest so deeply that rows lose their labels; the visual indent stops after eight levels
Related Components
- Accordion - Independent disclosure panels for prose
- SidebarNav - Application navigation hierarchy
- DataTable - Flat structured records