Tree View
A hierarchical tree for scene graphs, file trees, and outlines. You supply a
flat array of TreeDataItem nodes (each pointing at its parentId); the
useTreeState hook derives the visible rows — with indent and guideline
metadata — and manages expand-collapse state. Icons and label rendering are
left to the caller. For trees with thousands of nodes, swap TreeView for
VirtualTreeView — it shares the same props plus a height.
Basic
Pass the visibleData from useTreeState to data, and wire expandedItems,
onToggleItem, and hasDescendants so collapsible folders get a chevron.
getIcon returns whatever node icon you want, with the expanded flag for
folder-open states.
Connectors
The branch into a row is an arc on every row, not only the last child — a column of connectors is one drawn path, and a corner that is square here and round there reads as two line systems in the same rail. A row with siblings below it keeps an unbroken vertical running past its branch; the arc leaves the trunk rather than replacing it.
The line is --tree-guide, not --faint. Rows hover and select, and a
translucent hairline takes a tint from each of those, so one connector ended up
drawn in several shades down a single column — reporting the row's state
instead of the structure it is there to describe. --tree-guide is the same
colour held still.
It is legible on the page and faint where it crosses a filled row (1.03:1 in light). That is the cost of a neutral line in a tree whose surfaces sit within 19 levels of each other — see Color → Lines that cross rows.
Selectable
Set isSelectable and control selection with selectedItemIds /
onSelectionChange. With the default selectionMode="multi", Cmd/Ctrl-click
toggles individual rows and Shift-click selects a range; selecting a parent
implicitly covers its descendants.
Searchable
TreeSearchBar is a controlled input with case-sensitive and regex toggles.
Feed its state into useTreeSearch, which returns the filteredData (matches
plus their ancestor paths) and a renderLabel that highlights the matched
substring. Pass filteredData back through useTreeState and hand
renderLabel to the TreeView.
Quiet rows
Three opt-outs tune the tree for dense sidebars (the Dream Studio repos list
uses all three). chevronPosition="trailing" moves a smaller, dimmed expand
chevron to sit right after the label text — collapsed it points left, toward
the label it reveals under. hoverSubtree={false} limits the hover wash to
the row under the cursor instead of highlighting the whole subtree as one
block. loneSelectionStyle="ring" draws a lone selected row with the same
accent ring group selections use, leaving the row background transparent
instead of the solid fill. A fourth, groupSelection="row", changes what
selecting a group MEANS: only the clicked row is marked — descendants are
untouched, and a selected group renders like any lone selection instead of
a ring around its whole subtree.
Selected runs — SelectionRun
When a list lets you pick several rows at once, wrap each contiguous stretch
of selected rows in a SelectionRun. The rows keep their own surface and a
single accent ring goes around the run, so five selected rows are one shape
rather than five repeated fills.
Five filled rows say "five things happened". One ringed block says "this is the selection" — which is the thing a bulk action is about to act on.
Click the rows below: selecting a neighbour grows a run, and deselecting one in the middle splits it. A gap in the selection is a second run, not a taller one.
The hover block rounds by the same rule, and breaks where a selected row interrupts it. Hovering a group highlights the group and its descendants as one shape; if one of those descendants is selected it paints its own surface instead, so the hover band genuinely ends above it and resumes below. Each of the three pieces rounds all four corners. Treating the selected row as part of the band left square corners butted against it — the one place a tree row showed a raw corner.
Give the run the same corner radius as the rows it wraps. The ring is
outset, so its inner curve is the run's radius: rows rounded any tighter pull
away from it and leave a sliver at every corner. The default is var(--radius);
override it with className if your rows are rounded differently.
The run owns the 2px gap between its rows, because that tight rhythm is part of
reading them as one block. The ring is outset and the run sits
above its neighbours, so a row below cannot clip it — but the list around it
still has to leave 2px of side room, or the ring's corners go under whatever
does the clipping. Native <div> attributes
are forwarded.
Props
TreeView / VirtualTreeView
| Prop | Type | Default | Description |
|---|---|---|---|
data | TreeDataItemWithMeta<T>[] | — | Flattened rows with metadata — pass visibleData from useTreeState. |
getIcon | (item: T, expanded?: boolean) => ReactNode | — | Icon for each node. Receives the expanded flag for folder-open states. |
expandedItems | Set<string> | — | IDs of expanded collapsible nodes. |
onToggleItem | (id: string) => void | — | Called when a chevron is clicked. |
onItemHover | (id: string | null) => void | — | Called on row mouse enter / leave. |
hoveredId | string | null | — | Controlled hovered row id. |
isSelectable | boolean | false | Enables row selection. |
selectedItemIds | Set<string> | — | Controlled set of selected node ids. |
onSelectionChange | (ids: Set<string>) => void | — | Called when the selection changes. |
selectionMode | 'single' | 'multi' | 'multi' | multi enables Cmd/Ctrl-click toggle and Shift-click range. |
hideExpand | boolean | false | Hide expand chevrons entirely. |
hasDescendants | (id: string) => boolean | () => false | Whether a node has children — pass the helper from useTreeState. |
renderLabel | (label: string, itemId: string) => ReactNode | identity | Custom label rendering — pass renderLabel from useTreeSearch for highlights. |
renderContextMenu | (item: T) => ReactNode | — | Right-click menu content for a row. |
hoverSubtree | boolean | true | Hovering a group also highlights its whole subtree as one block. Set false for row-only hover. |
loneSelectionStyle | 'fill' | 'ring' | 'fill' | Lone selected row: solid fill, or the accent ring group selections use (transparent background). |
chevronPosition | 'leading' | 'trailing' | 'leading' | trailing renders a smaller, dimmed chevron after the label text; collapsed points left. |
groupSelection | 'subtree' | 'row' | 'subtree' | 'row' = selecting a group marks only that row; descendants stay unmarked. |
className | string | — | Extra classes on the container. |
height | number | string | '100%' | VirtualTreeView only — container height for virtualization. |
overscan | number | 5 | VirtualTreeView only — extra rows rendered outside the viewport. |
TreeDataItem
| Field | Type | Description |
|---|---|---|
id | string | Unique node identifier. |
parentId | string | null | Parent id, or null for roots. |
label | string | Display text (also the search target). |
isCollapsible | boolean | Marks a node as an expandable folder. |
actions | ReactNode | Trailing action content shown on the row. |
disable | boolean | Dim and disable interaction for the row. |
selectable | boolean | Set false to exclude the row from selection. |
TreeSearchBar
| Prop | Type | Description |
|---|---|---|
searchQuery | string | Current query text. |
setSearchQuery | (query: string) => void | Updates the query. |
isCaseSensitive | boolean | Case-sensitive toggle state. |
setIsCaseSensitive | (value: boolean | ((prev: boolean) => boolean)) => void | Toggles case sensitivity. |
isRegex | boolean | Regex toggle state. |
setIsRegex | (value: boolean | ((prev: boolean) => boolean)) => void | Toggles regex mode. |
isRegexValid | boolean | Whether the current regex compiles — from useTreeSearch. |
searchResultsCount | number | Number of direct matches — from useTreeSearch. |
className | string | Extra classes on the container. |
useTreeState({ data, defaultExpanded?, expandedItems?, onToggleItem? })
| Returns | Type | Description |
|---|---|---|
visibleData | TreeDataItemWithMeta<T>[] | Flattened, metadata-tagged rows with collapsed subtrees hidden — pass to data. |
dataWithMeta | TreeDataItemWithMeta<T>[] | All rows with metadata, ignoring collapse state. |
expandedItems | Set<string> | Currently expanded node ids. |
toggleItem | (id: string) => void | Toggle one node's expanded state. |
expandAll | () => void | Expand every collapsible node. |
collapseAll | () => void | Collapse every node. |
hasDescendants | (id: string) => boolean | Whether a node has children. |
useTreeSearch({ data, searchQuery, isCaseSensitive?, isRegex? })
| Returns | Type | Description |
|---|---|---|
filteredData | T[] | Matching nodes plus their ancestor paths — feed into useTreeState. |
searchResultsCount | number | Count of direct label matches. |
isRegexValid | boolean | false when isRegex is on and the query is not a valid pattern. |
renderLabel | (label: string, itemId: string) => ReactNode | Label renderer that highlights matched substrings — pass to TreeView. |
hasActiveSearch | boolean | Whether a non-empty query is active. |