Tree
- Component overview: Folders-style tree navigation pattern — hierarchical lists for workspace / file-group navigation. API conventions follow antd
Tree. Documented under Patterns (composed scenario); implementation lives inpackages/design/src/components/Tree. - Interaction: Click any enabled row to select it. Expand / collapse is triggered only by the left chevron (which fades in on row hover / focus-within). Keyboard:
Enter/Spaceselect;ArrowLeft/ArrowRightcollapse / expand. Optional HTML5 drag-and-drop: before / inside / after drop zones, cycle prevention, disabled rows are not drop targets, and hover-to-expand on folders while dragging. - Implementation note: Tree item visuals are owned by
packages/design/src/components/Tree; there is noui/primitive — visuals are composed directly from design tokens. - Figma spec
Basic Usage
<Tree defaultExpandedKeys={['projects']} treeData={[ { key: 'all', title: 'All Recordings' }, { key: 'projects', title: 'Projects', children: [ { key: 'projects/web', title: 'Web Refactor' }, { key: 'projects/mobile', title: 'Mobile App' }, ], }, { key: 'starred', title: 'Starred' }, { key: 'trash', title: 'Trash', disabled: true }, ]} />
Tree does not own a separate "count" / "trailing meta" slot — compose count or other trailing meta directly into
title(e.g.<span>Projects <span className="text-(--Labels-Tertiary)">(99)</span></span>).
States
Four interaction states: Default / Hover / Selected / Disabled. Per the Figma spec, Selected shares the hover background by default (--Grays-Gray-1); pass selectedColor when the scenario needs a distinct selected tint.
<Tree defaultSelectedKeys={['selected']} treeData={[ { key: 'default', title: 'Default item — hover me' }, { key: 'selected', title: 'Selected item' }, { key: 'disabled', title: 'Disabled item', disabled: true }, ]} />
Custom selected color
Pass selectedColor on Tree to override the selected background. Accepts any CSS color or var() expression — typically a design token.
<Tree defaultSelectedKeys={['highlighted']} selectedColor="color-mix(in oklab, var(--Labels-Primary) 8%, transparent)" treeData={[ { key: 'default', title: 'Default item' }, { key: 'highlighted', title: 'Selected with custom color' }, { key: 'another', title: 'Another item' }, ]} />
Custom node icons
Each TreeDataNode can set icon to any ReactNode. It renders in a single 20×20 slot at the row's leading position. Match Figma by sizing to 20px and tinting with text-(--Labels-Tertiary). Set showIcon={false} on Tree to hide all node icons.
The expand chevron and node icon share the same slot for folder nodes — the chevron is hidden by default and fades in on hover / focus-within, replacing the icon with an opacity transition. Leaf nodes and disabled folders always show the icon.
icon also accepts a function ({ expanded, hover }) => ReactNode — typical use is switching between closed / open folder glyphs based on expanded:
<Tree defaultExpandedKeys={['root']} treeData={[ { key: 'root', title: 'Workspace', icon: ({ expanded }) => ( <svg className="size-5 text-(--Labels-Tertiary)" viewBox="0 0 20 20" fill="none" aria-hidden> {expanded ? ( <path d="M2.5 6.5H8L9.5 4.5H17.5V15.5C17.5 16.0523 17.0523 16.5 16.5 16.5H3.5C2.94772 16.5 2.5 16.0523 2.5 15.5V6.5Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /> ) : ( <path d="M2.5 5.5C2.5 4.94772 2.94772 4.5 3.5 4.5H8L9.5 6.5H16.5C17.0523 6.5 17.5 6.94772 17.5 7.5V15.5C17.5 16.0523 17.0523 16.5 16.5 16.5H3.5C2.94772 16.5 2.5 16.0523 2.5 15.5V5.5Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /> )} </svg> ), children: [ { key: 'root/docs', title: 'Guides', icon: <Plus className="size-5 text-(--Labels-Tertiary)" aria-hidden />, }, { key: 'root/api', title: 'API reference', icon: ( <svg className="size-5 text-(--Labels-Tertiary)" viewBox="0 0 20 20" fill="none" aria-hidden> <path d="M5 4.5H15V15.5H5V4.5Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /> <path d="M7 7.5H13M7 10H11M7 12.5H12" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> </svg> ), }, ], }, ]} />
Hover any of the folder rows above to see the chevron fade in over the folder icon.
Hover Actions
Use TreeDataNode.actions to render a trailing slot that appears on hover / focus-within (matches the Figma "Hover - More" variant), right-aligned via internal ml-auto. Clicks inside actions don't bubble to row onSelect / onExpand. Disabled rows never reveal actions.
When the action triggers a popover-style UI (dropdown menu, modal, etc.), use the function form (state) => ReactNode and wire the popover's onOpenChange to state.setActionsOpen. This keeps the row in its active visual while the popover is open — without it the row would revert to idle as soon as focus moves into the portal.
Row click still does not trigger selection — selection is a separate concern. The
setActionsOpenAPI only controls visual continuity (background + actions visibility + chevron swap) during the popover's lifetime.
Open a dropdown menu
Wrap a "more" trigger button with DropdownMenu and pass declarative items. Forward onOpenChange to setActionsOpen so the row stays active while the menu is open.
function MoreActionsDropdownDemo() { const buildMoreAction = (label) => ({ setActionsOpen }) => ( <DropdownMenu onOpenChange={setActionsOpen} items={[ { label: 'Rename', onSelect: () => alert(`Rename ${label}`) }, { label: 'Duplicate', onSelect: () => alert(`Duplicate ${label}`) }, { type: 'separator' }, { label: 'Delete', variant: 'destructive', onSelect: () => alert(`Delete ${label}`) }, ]} > <button type="button" className="size-5 inline-flex items-center justify-center rounded-(--Radius_5) text-(--Labels-Tertiary) hover:bg-(--Grays-Gray-2) outline-none" aria-label={`More actions for ${label}`} > ⋯ </button> </DropdownMenu> ) return ( <Tree defaultExpandedKeys={['workspace']} treeData={[ { key: 'workspace', title: 'Workspace', actions: buildMoreAction('Workspace'), children: [ { key: 'workspace/inbox', title: 'Inbox', actions: buildMoreAction('Inbox') }, { key: 'workspace/drafts', title: 'Drafts', actions: buildMoreAction('Drafts') }, ], }, ]} /> ) }
Click to open a modal
Wrap the trigger with Dialog (a.k.a. Modal). The button only opens the modal — row onSelect / onExpand stay unaffected. Forward Dialog's onOpenChange to setActionsOpen so the row keeps its active background while the modal is open.
function MoreActionsModalDemo() { const [activeKey, setActiveKey] = React.useState(null) const buildSettingsTrigger = (node) => ({ setActionsOpen }) => ( <Dialog title={`Folder settings — ${node.title}`} onOpenChange={setActionsOpen} content={ <div className="flex flex-col gap-(--Spacing_8) text-(--Labels-Secondary)"> <p>You opened the settings modal for "{node.title}".</p> <p>Hook real form fields here; closing returns to the tree without losing selection.</p> </div> } okText="Save" cancelText="Cancel" onOk={() => setActiveKey(node.key)} > <button type="button" className="size-5 inline-flex items-center justify-center rounded-(--Radius_5) text-(--Labels-Tertiary) hover:bg-(--Grays-Gray-2) outline-none" aria-label={`Settings for ${node.title}`} > ⋯ </button> </Dialog> ) const nodes = [ { key: 'projects', title: 'Projects' }, { key: 'archive', title: 'Archive' }, ] return ( <div className="flex flex-col gap-(--Spacing_8)"> <Tree treeData={nodes.map((node) => ({ ...node, actions: buildSettingsTrigger(node) }))} /> {activeKey != null && ( <div className="text-(length:--Font-Size-Caption) text-(--Labels-Tertiary)"> Last saved: <code>{activeKey}</code> </div> )} </div> ) }
Long Titles
Tree width is controlled entirely by its container — the component itself has no built-in max-width. When title text overflows, the right edge fades via a CSS mask-image gradient to indicate truncated content. Actions still appear on hover, shrinking the available title space.
Overflowing titles also get an automatic tooltip (300ms hover delay) showing the full content. Overflow is detected by rendering an offscreen clone of the title node and comparing its natural width against the container — not scrollWidth — so it works with the gradient mask and arbitrary ReactNode titles. The measurement re-runs on container resize via ResizeObserver. Titles that fit never get a tooltip.
function LongTitleDemo() { const buildAction = (label) => ({ setActionsOpen }) => ( <DropdownMenu onOpenChange={setActionsOpen} items={[ { label: 'Rename', onSelect: () => alert(`Rename ${label}`) }, { label: 'Delete', variant: 'destructive', onSelect: () => alert(`Delete ${label}`) }, ]} > <button type="button" className="size-5 inline-flex items-center justify-center rounded-(--Radius_5) text-(--Labels-Tertiary) hover:bg-(--Grays-Gray-2) outline-none" aria-label={`More actions for ${label}`} > ⋯ </button> </DropdownMenu> ) return ( <div style={{ width: 220 }}> <Tree defaultExpandedKeys={['folder']} treeData={[ { key: 'long1', title: 'This recording has an extremely long title that overflows', actions: buildAction('long1'), }, { key: 'folder', title: 'Project folder with a very long name', actions: buildAction('folder'), children: [ { key: 'child1', title: 'Another very long child recording title here', actions: buildAction('child1'), }, { key: 'child2', title: 'Short title' }, ], }, ]} /> </div> ) }
Hover any item to see the action button appear and the gradient-masked title shrink — keep hovering an overflowed title for 300ms to see the full-content tooltip. Resize the container
widthto test different breakpoints.
Controlled Expansion
Use expandedKeys + onExpand for fully controlled expand state. Use selectedKeys + onSelect for controlled selection.
function ControlledTree() { const [expandedKeys, setExpandedKeys] = React.useState(['root']) const [selectedKeys, setSelectedKeys] = React.useState([]) return ( <Tree treeData={[ { key: 'root', title: 'Workspace', children: [ { key: 'docs', title: 'Documents' }, { key: 'audio', title: 'Audio' }, ], }, ]} expandedKeys={expandedKeys} selectedKeys={selectedKeys} onExpand={(keys) => setExpandedKeys(keys)} onSelect={(keys) => setSelectedKeys(keys)} /> ) }
Drag and Drop
Native HTML5 drag-and-drop (no extra runtime). Pass draggable and onDrop; the component does not mutate treeData—use moveTreeNode from @plaud/design or your own logic to rebuild the array. Drop zones follow a 25% / 50% / 25% vertical split on each row (before / inside / after); leaf rows collapse the middle zone into after. Dropping an ancestor onto its descendant is blocked. Disabled rows are not valid drop targets. Collapsed folders auto-expand after 500ms of continuous dragOver (fires onExpand; in controlled mode, update expandedKeys yourself).
Drag visuals follow the Figma spec: the dragged source row shows a 1px Separators/Emphasized inset outline; gap drops render a Labels/Link dot-and-line indicator in the 4px row gap (an inside drop highlights the whole row); and the browser drag image is replaced with a white, shadowed preview pill built from the row's icon + title via setDragImage.
function DragTree() { const [treeData, setTreeData] = React.useState([ { key: 'a', title: 'Alpha' }, { key: 'b', title: 'Beta', children: [ { key: 'b1', title: 'Beta-1' }, { key: 'b2', title: 'Beta-2' }, ], }, ]) const [expandedKeys, setExpandedKeys] = React.useState(['b']) return ( <Tree treeData={treeData} expandedKeys={expandedKeys} onExpand={(keys) => setExpandedKeys(keys)} draggable onDrop={(info) => { setTreeData((prev) => moveTreeNode(prev, info.dragNode.key, info.dropNode.key, info.dropPosition), ) }} /> ) }
External Drag
Pass onExternalDrop to accept items dragged from outside the tree (e.g. a file list on the right panel). The callback receives dropNode, dropPosition, and the original DragEvent so you can read any dataTransfer payload. Use allowExternalDrop to restrict which drop zones are valid. Collapsed folders still auto-expand after 500ms hover — same as internal drag.
function ExternalDropDemo() { const [expandedKeys, setExpandedKeys] = React.useState([]) const [log, setLog] = React.useState(null) const folders = [ { key: 'documents', title: 'Documents', children: [ { key: 'work', title: 'Work' }, { key: 'personal', title: 'Personal' }, ], }, { key: 'media', title: 'Media', children: [ { key: 'photos', title: 'Photos' }, { key: 'videos', title: 'Videos' }, ], }, { key: 'archive', title: 'Archive', children: [{ key: 'archive-2023', title: '2023' }], }, ] const files = [ { key: 'report.pdf', label: '📄 report.pdf' }, { key: 'photo.jpg', label: '🖼 photo.jpg' }, { key: 'notes.txt', label: '📝 notes.txt' }, { key: 'video.mp4', label: '🎬 video.mp4' }, ] return ( <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}> {/* 左侧:文件夹树 */} <div style={{ width: 200, border: '1px solid #e5e5e5', borderRadius: 8, padding: '8px 0' }}> <div style={{ padding: '0 12px 6px', fontSize: 12, color: '#999' }}>Folders</div> <Tree treeData={folders} expandedKeys={expandedKeys} onExpand={(keys) => setExpandedKeys(keys)} onExternalDrop={({ dropNode, dropPosition, event }) => { const fileKey = event.dataTransfer.getData('application/x-file-key') const posLabel = dropPosition === 0 ? 'inside' : dropPosition === -1 ? 'before' : 'after' setLog(`"${fileKey}" dropped ${posLabel} "${dropNode.title}"`) }} /> </div> {/* 右侧:文件列表 */} <div style={{ flex: 1 }}> <div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}> Files — drag into a folder (hover 1s on a collapsed folder to expand it) </div> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> {files.map((file) => ( <div key={file.key} draggable onDragStart={(e) => { e.dataTransfer.setData('application/x-file-key', file.key) e.dataTransfer.effectAllowed = 'copy' }} style={{ padding: '6px 12px', border: '1px solid #e5e5e5', borderRadius: 6, cursor: 'grab', fontSize: 14, background: '#fafafa', userSelect: 'none', }} > {file.label} </div> ))} </div> {log && ( <div style={{ marginTop: 12, fontSize: 13, padding: '6px 10px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 6, color: '#166534', }} > ✓ {log} </div> )} </div> </div> ) }
Size & Token
| Element | Value |
|---|---|
| Row height | min-height 32px (Spacing_32) |
| Row padding | left 8px / right 4px |
| Row gap (slot / title) | 8px (Spacing_8) |
| Nesting indent | first child level 12px, then +16px per level (indent default 16) |
| Leading slot (chevron / icon, shared) | 20×20 |
| Font | Body 14 / 20 (Font-Size-Body / Line-Height-Body) |
| Drop indicator | ∅8 dot (2px stroke) + 2px line, rendered in the 4px row gap |
| Element | Token |
|---|---|
| Title color (Default) | Labels/Primary#000000 |
| Switcher / Icon color | Labels/Tertiary#757575 |
| Hover background | Grays/Gray-1#EBEBEB |
| Selected background (default) | Grays/Gray-1#EBEBEB |
| Dragged source outline | Separators/Emphasized#CCCCCC |
| Drop indicator (dot + line) | Labels/Link#1573D1 |
| Drag preview background | Grays/White#FFFFFF |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
treeData | TreeDataNode[] | - | Tree data (required) |
expandedKeys | string[] | - | Controlled expanded keys |
defaultExpandedKeys | string[] | [] | Default expanded keys |
defaultExpandAll | boolean | false | Expand all expandable nodes on first render |
selectedKeys | string[] | - | Controlled selected keys |
defaultSelectedKeys | string[] | [] | Default selected keys |
onExpand | (keys, { node, expanded }) => void | - | Expand / collapse callback |
onSelect | (keys, { node, selected }) => void | - | Selection callback |
draggable | boolean | ((node: TreeDataNode) => boolean) | - | Enable drag source; function filters per node |
allowDrop | (info: TreeAllowDropInfo) => boolean | - | Return false to reject a drop (cycle always rejected) |
onDragStart | (info: TreeDragNodeInfo) => void | - | Drag start on a row |
onDragEnter | (info: TreeDragEnterInfo) => void | - | Drag enters a row |
onDragLeave | (info: TreeDragNodeInfo) => void | - | Drag leaves a row |
onDragEnd | (info: TreeDragNodeInfo) => void | - | Drag ended (including after drop) |
onDrop | (info: TreeDropInfo) => void | - | Successful drop; caller updates treeData |
allowExternalDrop | (info: TreeExternalAllowDropInfo) => boolean | - | Return false to reject an external drop |
onExternalDrop | (info: TreeExternalDropInfo) => void | - | External item dropped; read source data from info.event.dataTransfer |
showIcon | boolean | true | Whether to render node icon |
indent | number | 16 | Indent per nested level (px); per Figma the first child level indents indent - 4 |
selectedColor | string | var(--Grays-Gray-1) | Selected row background; any CSS color / var() expression |
className | string | - | Root className |
TreeDropPosition is -1 (before) | 0 (inside, first child) | 1 (after). TreeDropInfo includes dropToGap and isCrossParent. Immutable helpers: moveTreeNode(treeData, dragKey, dropKey, dropPosition), isDescendant(treeData, ancestorKey, candidateKey), and resolveDropPosition({ clientY, rowTop, rowHeight, isLeafRow }) (same row geometry as the built-in drop handler) are exported from @plaud/design.
TreeDataNode
| Field | Type | Description |
|---|---|---|
key | string | Unique node key (required) |
title | ReactNode | Node label (required). Compose count / trailing meta here. |
icon | ReactNode | ((state: { expanded; hover }) => ReactNode) | Leading icon (renders in 20×20 slot; folder rows fade to chevron on hover). Function form receives expanded / hover for swap glyphs. |
actions | ReactNode | ((state: TreeNodeActionsState) => ReactNode) | Trailing hover-only slot (e.g. more menu). Appears on row hover / focus-within or while a popover opened from within is still open. Clicks don't bubble. Use the function form and forward popover onOpenChange to state.setActionsOpen to keep the row active while the popover stays open. |
children | TreeDataNode[] | Child nodes |
isLeaf | boolean | Force treat node as a leaf |
disabled | boolean | Disables selection, dragging, and using the row as a drop target. Hover does not reveal chevron or actions. |
className | string | Per-node className |