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 (500ms hover delay) showing the full content. Overflow is detected on pointer entry or focus 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. Titles that fit never get a tooltip.
Use each TreeDataNode's tooltip field to configure its automatic tooltip placement, delay, collision behavior, and styling. Every placement preset uses a fixed sideOffset of 8 and takes precedence over lower-level side / align / sideOffset options. Tree retains ownership of the content, trigger structure, and controlled open state. A node in a left sidebar should prefer the right side; Radix collision handling may still flip it when the preferred side has insufficient space.
function LongTitleDemo() { const tooltip = { placement: 'right' } 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', tooltip, actions: buildAction('long1'), }, { key: 'folder', title: 'Project folder with a very long name', tooltip, actions: buildAction('folder'), children: [ { key: 'child1', title: 'Another very long child recording title here', tooltip, 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 500ms to see the full-content tooltip on its preferred right side. Resize the container
widthto test different breakpoints and collision fallback.
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 only while hovering the middle inside zone (fires onExpand; in controlled mode, update expandedKeys yourself); the edge gap zones never auto-expand.
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 centered in the measured physical row gap (normally 4px, or 8px after a folder row); and an inside drop highlights the whole row. The visible indicator is the drop result's source of truth, including when the pointer is released directly in the root gap, so pixel-level release drift cannot change the displayed destination. 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), ) }} /> ) }
Drag a row that has actions
When a draggable row also renders several hover-only actions (e.g. add / export / rename buttons plus a ⋯ more menu), the dragged source outline must stay complete behind the actions. The actions container fills the row's right edge with the row background to mask the title gradient; during drag it insets by 1px top / bottom so it no longer clips the source row's inset outline. Hover a folder to reveal its buttons, then drag it — the top / bottom outline under the buttons remains visible.
function DragWithActionsTree() { const [treeData, setTreeData] = React.useState([ { key: 'root', title: 'My Files' }, { key: 'imported', title: 'Imported', children: [ { key: 'imported/test1', title: 'test1' }, { key: 'imported/test2', title: 'test2' }, ], }, ]) const [expandedKeys, setExpandedKeys] = React.useState(['imported']) const iconBtnClass = 'size-5 inline-flex items-center justify-center rounded-(--Radius_5) text-(--Labels-Tertiary) hover:bg-(--Grays-Gray-2) outline-none' const buildActions = (label) => ( <> <button type="button" className={iconBtnClass} aria-label={`Add to ${label}`}> + </button> <button type="button" className={iconBtnClass} aria-label={`Export ${label}`}> ↗ </button> <button type="button" className={iconBtnClass} aria-label={`Rename ${label}`}> ✎ </button> <DropdownMenu items={[ { label: 'Rename', onSelect: () => alert(`Rename ${label}`) }, { label: 'Delete', variant: 'destructive', onSelect: () => alert(`Delete ${label}`) }, ]} > <button type="button" className={iconBtnClass} aria-label={`More actions for ${label}`}> ⋯ </button> </DropdownMenu> </> ) const withActions = (nodes) => nodes.map((node) => ({ ...node, actions: buildActions(node.title), children: node.children ? withActions(node.children) : undefined, })) return ( <Tree treeData={withActions(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> ) }
Large Trees
Trees with thousands of rows make every ancestor layout invalidation expensive — the browser still walks each node's structural boxes even when they are far off-screen. Tree skips that work automatically; there is nothing to turn on.
Each subtree gets content-visibility: auto, so the browser omits its rendering work (style, layout and paint) while the element keeps a placeholder box of the exact right height.
Unlike a virtualized list, nodes are never removed from the DOM. This is a normative requirement of the CSS Containment spec, not a browser quirk: skipped contents under auto "must still be available as normal to user-agent features such as find-in-page, tab order navigation, etc., and must be focusable and selectable as normal". A find-in-page hit un-skips the subtree and scrolls to it automatically — which is why this is preferable to virtualization whenever node text must stay searchable.
Skipping needs two CSS declarations whose browser support differs — content-visibility: auto (Chrome/Edge 85+, Firefox 125+, Safari 18+) and contain-intrinsic-size (Safari 17+). Both are feature-detected on mount; a browser missing either one simply renders everything, with no console noise. Roughly 6% of traffic lands there today.
Placeholder heights are resolved from --pd-tree-row-height, so they are only correct while the real row height agrees with that variable. If it ever doesn't, Tree falls back to rendering everything for the rest of that tree's life: slower, but correct. See Custom row height for what breaks the agreement and how the fallback is detected.
Custom row height
Row height is customizable — override --pd-tree-row-height:
<Tree className="[--pd-tree-row-height:72px]" treeData={treeData} />
Placeholder sizes are emitted as calc() over that same variable, so the row's own min-height and its off-screen placeholder always resolve to one value and cannot disagree.
Do not force the height any other way. These all change the visuals — descendant selectors even win on specificity — but they bypass the variable, leaving the placeholder at the old size, collapsing scrollHeight and tripping the fallback:
| Bypass | Why it breaks |
|---|---|
[&_.pd-tree-item]:min-h-* / h-* / py-* | Outranks the component's own class; placeholders still use the variable |
Caller stylesheet on .pd-tree-item (especially !important) | Same, and invisible to any static check |
Height utilities in node.className | Affects one row; placeholder still uses the variable |
When a row's real height disagrees with the declared variable, Tree falls back to full rendering and, in development, warns. It checks the first row on mount, and then every row as it comes back on screen — listening for contentvisibilityautostatechange and batching each 0.5s of events into one pass, so a tall row deep in the list is caught too, not just the first one. The check validates the result, so it catches stylesheets, !important, font swaps and content that simply outgrows the row alike.
One property the tree cannot enforce, only detect: row height must be uniform within a tree. Placeholders resolve from a single row height; measuring per row would be O(n) forced layout and a new source of jank. A tree with mixed row heights therefore trips the fallback as soon as a differing row comes on screen, and runs unoptimized from then on.
content-visibility: auto also brings paint containment, and each subtree wrapper's box coincides with the row's. Anything painted outside the row box is therefore clipped away on all four sides — an outset ring, an outline with outline-offset, a drop shadow. Nothing errors and computed style still reports a colour and a width; the pixels are simply never drawn. The component's own keyboard focus ring is drawn inside the row for this reason. If you pass decorations through node.className, use the inset variants (inset-ring-*, inset-shadow-*).
Nodes that should not occupy layout at all — invisible placeholders that only exist so a parent renders its expand chevron — must be marked hidden: true rather than given a hidden class, so the tree can exclude them from those height calculations. Hiding only the row leaves the wrapper consuming a gap, and since skipping is always on, so is this requirement.
To check whether a node is actually being skipped, listen for the contentvisibilityautostatechange event rather than probing geometry — reading layout can force the element to render and hide the very state you are measuring.
Size & Token
| Element | Value |
|---|---|
| Row height | min-height 32px (--pd-tree-row-height, defaults to 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, centered in the measured 4px / 8px physical 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. |
hidden | boolean | Row takes no layout at all (display: none on the node wrapper). For invisible placeholder children that exist only to make a parent expandable. Prefer this over passing className: 'hidden' — hiding only the row leaves the wrapper as a flex item that still consumes a gap, which would skew the off-screen placeholder heights. |
tooltip | TreeTitleTooltipProps | Per-node automatic Tooltip options. placement supports top, right, bottom, left, and each side's -start / -end variants, resolving to Tooltip side / align with sideOffset: 8. Content, trigger structure, and controlled open state stay internal. |
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 |