Skip to main content

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 in packages/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 / Space select; ArrowLeft / ArrowRight collapse / 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 no ui/ primitive — visuals are composed directly from design tokens.
  • Figma spec

Basic Usage

Result
Loading...
Live Editor
<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.

Result
Loading...
Live Editor
<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.

Result
Loading...
Live Editor
<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:

Result
Loading...
Live Editor
<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 setActionsOpen API 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.

Result
Loading...
Live Editor
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.

Result
Loading...
Live Editor
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.

Result
Loading...
Live Editor
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 width to test different breakpoints.

Controlled Expansion

Use expandedKeys + onExpand for fully controlled expand state. Use selectedKeys + onSelect for controlled selection.

Result
Loading...
Live Editor
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.

Result
Loading...
Live Editor
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.

Result
Loading...
Live Editor
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

ElementValue
Row heightmin-height 32px (Spacing_32)
Row paddingleft 8px / right 4px
Row gap (slot / title)8px (Spacing_8)
Nesting indentfirst child level 12px, then +16px per level (indent default 16)
Leading slot (chevron / icon, shared)20×20
FontBody 14 / 20 (Font-Size-Body / Line-Height-Body)
Drop indicator∅8 dot (2px stroke) + 2px line, rendered in the 4px row gap
ElementToken
Title color (Default)Labels/Primary#000000
Switcher / Icon colorLabels/Tertiary#757575
Hover backgroundGrays/Gray-1#EBEBEB
Selected background (default)Grays/Gray-1#EBEBEB
Dragged source outlineSeparators/Emphasized#CCCCCC
Drop indicator (dot + line)Labels/Link#1573D1
Drag preview backgroundGrays/White#FFFFFF

Props

PropTypeDefaultDescription
treeDataTreeDataNode[]-Tree data (required)
expandedKeysstring[]-Controlled expanded keys
defaultExpandedKeysstring[][]Default expanded keys
defaultExpandAllbooleanfalseExpand all expandable nodes on first render
selectedKeysstring[]-Controlled selected keys
defaultSelectedKeysstring[][]Default selected keys
onExpand(keys, { node, expanded }) => void-Expand / collapse callback
onSelect(keys, { node, selected }) => void-Selection callback
draggableboolean | ((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
showIconbooleantrueWhether to render node icon
indentnumber16Indent per nested level (px); per Figma the first child level indents indent - 4
selectedColorstringvar(--Grays-Gray-1)Selected row background; any CSS color / var() expression
classNamestring-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

FieldTypeDescription
keystringUnique node key (required)
titleReactNodeNode label (required). Compose count / trailing meta here.
iconReactNode | ((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.
actionsReactNode | ((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.
childrenTreeDataNode[]Child nodes
isLeafbooleanForce treat node as a leaf
disabledbooleanDisables selection, dragging, and using the row as a drop target. Hover does not reveal chevron or actions.
classNamestringPer-node className