Tree view
A hierarchy to open, select from, and rearrange: TreeView over React Aria’s Tree with tri-state selection and drag and drop. Two sizes, three selection modes.
Tree view
A hierarchy to open, select from, and rearrange: TreeView over React Aria’s Tree with tri-state selection and drag and drop. Two sizes, three selection modes.
Demo
The controls are the root’s own: size, connectors, selection, and drag and drop. A three-level tree, opened all the way.
Three components, one tree
TreeView owns the state and the axes, TreeView.Item owns one row’s identity, and TreeView.ItemContent owns what the row draws. The copied snippet carries all three, with an id and a textValue on every row, because no single element reproduces what the preview shows. With drag and drop on it also carries an onMove: draggable alone lifts rows and gives them nowhere to land, read from React Aria’s useDragAndDrop, which enables drops only when a drop handler exists.Contextual demo
A tree only makes sense beside the thing it organizes. The preview puts two in a real screen.
No consumer in this repo
The family
One root with three components on it, and the helpers in a second file. The root is tree-view.tsx’s only named export.
| Component | Description | Import from |
|---|---|---|
| TreeView | The root: React Aria’s Tree with the kit’s size, showConnectors, draggable, onReorder, and onMove on top, the cascading selection layered over selectionMode, and a context carrying size and connectors to every row. | @/components/application/tree-view/tree-view |
| TreeView.Item | One row, with an id and a textValue. Holds an ItemContent and, for a parent, nested Items. A thin wrapper over React Aria’s TreeItem that adds the 2px row gap and hides the row’s own outline. | @/components/application/tree-view/tree-view |
| TreeView.ItemContent | What a row draws: the connectors, the chevron, the checkbox, the icon or avatar, the label, an optional trailing action, and the drag handle. Every visual decision lives here; it reads size and connectors from the root’s context. | @/components/application/tree-view/tree-view |
| TreeView.DropIndicator | The 2px brand line React Aria places between rows while something is dragged. The root renders it for you when draggable is on; it is exported for a custom dragAndDropHooks. | @/components/application/tree-view/tree-view |
| cascadeSelection | The tri-state arithmetic in tree-view-utils.ts, with getDescendants and getAncestors beside it: selecting a parent selects every descendant, clearing it clears them, and a parent follows its children up, indeterminate while some are selected, checked once the last one is. The root calls cascadeSelection on every change in multiple mode. | @/components/application/tree-view/tree-view-utils |
| createDragPreview | Clones the dragged rows’ DOM into the drag preview, strips their connectors, and adds a count badge when more than one row is lifted. Imperative DOM, built once per drag. | @/components/application/tree-view/tree-view-utils |
Built from
Six pieces make up the tree; two have their own docs pages.
Component | Where it’s used here |
|---|---|
| CheckboxBase | The 16px box on every row when selectionMode is set, from base/checkbox, painted from the row’s own selected, indeterminate, disabled, and focus state. Its indeterminate bar was widened from 5.8px to 7px on 2026-08-23, read off this sheet’s checkbox instances. |
| @untitledui/icons | ChevronRight and ChevronDown for the chevron, DotsGrid for the drag handle, at 16px and text-fg-quaternary. The row icons are yours. |
The variant space
The sheet’s three Types — Simple, Folder only, Advanced — are settings of four props: icons, connectors, selection, drag. All live.
Size moves the row, not the indent
py-1.5 over the label’s 20px line at sm, 32px, and py-2 at md, 36px; the icon is size-4 then size-5 with a 6px then 8px gap to the label. The label stays 14/20 semibold, the chevron stays 16px, the checkbox stays the 16px CheckboxBase at both sizes, as on the sheet, and a level is 24px either way. The avatars above are sized to the slot by the page, not the kit: an element in the icon slot renders untouched.Connectors
showConnectors swaps indent padding for guide lines at the same 24px per level, so rows do not move when they turn on.
Three connector types, one rule
line when the row, or the ancestor at that level, has siblings after it; an end elbow, the border-s border-b corner with an 8px arc, when the row is the last child at its own level; and none, a bare 16px box, when an ancestor was a last child. The line is a 1px bg-border-primary hairline 42px tall at sm and 44px at md, centered in its box so it meets the rows above and below; the elbow is 10×22px at sm and 12×24px at md, sitting 8px up from the bottom. Both are border-primary since 2026-08-23, one ramp step darker than the kit’s border-secondary, which the sheet draws. border-primary is a semantic alias, interface-300 in light mode and interface-700 in dark, so the lines flip with the theme.Selection
Three React Aria modes. none is navigation; single and multiple draw the same checkbox; multiple adds the kit’s cascade.
The cascade is the kit’s, and it runs on change
cascadeSelection: a key added selects its descendants, a key removed clears them, and every touched ancestor is re-derived deepest first, selected when every descendant key is in the set and cleared otherwise. A parent that is cleared but has selected descendants is drawn indeterminate by ItemContent, which counts them on each render. So the cascade runs both ways: a folder whose files were checked one at a time completes to checked on the last press. It did not before a same-day edit to getDescendants, which now counts item nodes only; the register on the Code tab has the history. The diff is what makes defaultSelectedKeys literal: the set is stored as given, with no cascade, so a parent passed alone starts checked over unchecked children. Pass it with its descendants. Selecting a collapsed folder selects what is inside it, since the walk goes through the collection rather than the DOM.The indeterminate bar is the checkbox’s
CheckboxBase, and it was widened on 2026-08-23 from 5.8px to 7px in the 16px box, read off this sheet’s checkbox instances: the 10px-wide svg had scaled its 8.17-unit path down, and a 12px box scales it to the sheet’s 7px with a 1.7px stroke. That edit lives in base/checkbox/checkbox.tsx and reaches every sm checkbox, not only the tree’s; md was not read (the Figma seat’s call limit) and is unchanged.Drag and drop
draggable turns on the built-in hooks: pointer and keyboard sources, a cloned preview, a drop line. Landing is yours.
Select, then drag
slot="drag" button pointer-events: none, so for the mouse the handle is a grip, not a control: a press on it passes through to the row, which is what lifts (measured 2026-08-29). The kit shows that button while the row is selected or, since 2026-08-23, indeterminate, and, since 2026-08-29, while a draggable row is hovered, which is what the sheet draws. The keyboard cannot hover, and a hidden handle is out of the focus order, so keyboard drag is select first, then ArrowRight into the row to the handle, then Enter. With selectionMode none no row is ever selected and the keyboard cannot drag; a draggable tree needs a selection mode. One more thing the toggle above hides: React Aria calls its drag hooks only while they are present, so draggable cannot flip on a mounted tree without changing the hook order; the demo remounts the root with a key when the toggle moves, and its rows survive because the data lives in useTreeData outside the tree.The preview is the rows themselves
createDragPreview clones the dragged rows’ DOM into the preview, removes their connector boxes, and stacks them 2px apart, so a lifted row looks like the row it was. Two or more rows get a 20px count badge at the top end corner, in var(--color-fg-white) on var(--color-bg-brand-solid) with a #2563eb fallback since 2026-08-23, this theme’s brand-600 rather than the legacy purple. The preview is built imperatively once per drag, so the badge’s styles are inline rather than classes.States
Hover, selected, indeterminate, focus, drop target, disabled. The sheet draws the first three; the rest are kit extras.
Four backgrounds and one ring
bg-primary_hover; selected and indeterminate share bg-secondary and go bg-secondary_hover on hover; a drop target takes bg-secondary_hover with a 2px outline while a drag hovers it. Those four are the sheet’s. Focus is a 2px outline-focus-ring drawn 2px inside the row when React Aria reports focus-visible, and, since 2026-08-23, a 2px ring at a 2px offset on the chevron and the drag handle when the keyboard reaches them; the checkbox draws its own through CheckboxBase. A disabled row is opacity-50 with a not-allowed cursor and, with React Aria’s default disabledBehavior of all, out of the arrow-key order and unselectable. Under forced colors the fills are gone, so selected, indeterminate, and drop-target rows take Highlight with HighlightText and a disabled row GrayText, all added 2026-08-23.