skrewww

Content & Data

Tree View

Tree View is a hierarchical, keyboard-navigable tree — file explorers, nested category browsers, org charts. Composes Content/Tree Item rows with roving-tabindex keyboard navigation and depth-based indentation.

Composed from Tree Item rows — no top-level variants of its own

betaReact AvailableFigma AvailableDocs Partial
React last updated
2026-07-18
Documentation last updated
2026-07-18
Accessibility target
WCAG 2.2 AA (target)
Version
0.1.0-beta

Known open questions

  • Multi-select is a deferred v2 — not shown in the Figma reference and not built here.
  • Drag-and-drop reordering, virtualization, and async/lazy-loaded children are all out of scope — none are shown in the Figma reference.

File explorer

Depth-based indentation (20px per level, not a fixed variant) with roving-tabindex keyboard navigation — arrow keys move, expand, and collapse; Enter/Space selects.

Project files

src
index.tsx
README.md

Purpose

A hierarchical, keyboard-navigable tree for file explorers, nested category browsers, and org charts, composed from Content/Tree Item rows.

Anatomy

Tree View renders a flat, depth-first list of visible Tree Item rows (role="treeitem") inside a role="tree" container — not nested DOM groups. Each row is Chevron (hidden on leaf nodes) + an optional consumer-supplied icon (16x16 ReactNode slot, matching Figma's deliberate lack of a formal icon-swap property) + Label. Indentation is depth * 20px computed as padding-left per row, matching the 20px-per-depth unit confirmed in Figma's composed example — never a fixed set of per-depth variants.

Variants and states

default · hover · selected

When to use

Content with genuine hierarchical depth where indentation communicates real structure, and the user needs to expand/collapse and select individual nodes.

When not to use

Flat, non-nested lists — use List Item. Multi-select, drag-and-drop reordering, virtualization, and async/lazy-loaded children are all out of scope in this Beta.

Accessibility

role="tree" containing a flat, depth-first list of role="treeitem" rows (not nested DOM groups) — aria-level, aria-setsize, and aria-posinset are set explicitly on every row since DOM nesting doesn't convey depth here. aria-expanded is present only on rows with children. Roving tabindex keeps exactly one row in the Tab sequence; arrow keys move/expand/collapse, Enter/Space selects.

Keyboard behavior

Roving tabindex — exactly one row is in the Tab sequence at a time. ArrowDown/ArrowUp move focus between visible rows. ArrowRight expands a collapsed node and moves focus onto its newly-visible first child (or moves directly to the first child if already expanded); a no-op on leaf nodes. ArrowLeft collapses an expanded node in place, or moves focus to its parent if already collapsed or a leaf. Enter and Space select the focused row. Clicking the chevron toggles expand/collapse only; clicking the row body selects only — the two are deliberately independent actions.

Common mistakes

Building indentation as a fixed per-component property instead of computing depth * 20px per row (Figma's own description calls this out as the #1 mistake); inventing a fixed folder/file icon-swap enum when Figma deliberately leaves icon selection as a consumer-supplied slot; building multi-select, drag-and-drop, or virtualization that aren't part of this scope.

Properties

data (TreeNode[]: { id, label, icon?, children? }). expanded/defaultExpanded + onExpandedChange (controlled/uncontrolled). selected/defaultSelected + onSelectedChange (controlled/uncontrolled, single-select only).

When should I use Tree View instead of List Item?

Tree View is for content with genuine hierarchical depth where indentation communicates real structure (file trees, nested categories, org charts). List Item is for flat, non-nested rows — don't reach for Tree View just to get List Item's visual density.

Is Tree View's expanded/selected state controlled or uncontrolled?

Both, independently — expanded (string[] of node ids) and selected (a single string | null) each use the same useControllableState hook as Accordion, Dialog, Drawer, CalendarGrid's range mode, and Data Table's sort state. Pass expanded + onExpandedChange or selected + onSelectedChange for controlled usage, or defaultExpanded / defaultSelected for uncontrolled.

Does Tree View support selecting multiple nodes?

No — single-select only in this Beta. Multi-select is a deferred v2 with no clear signal it's needed yet, and isn't shown anywhere in the Figma reference.

Why is indentation computed instead of a Figma variant?

Figma's own component description calls this out as the #1 common mistake: building indentation as a fixed per-component property. The real anatomy demonstrates it as a genuine per-instance depth spacer (verified at exactly 20px x depth in the composed example), so the React implementation computes the same depth * 20px value as padding-left rather than hardcoding per-level classes or variants.

Why doesn't the Icon have its own swap property?

Figma deliberately leaves icon selection as a consumer-supplied slot — different rows in the Figma demo use different icons purely through manual instance swaps, with no property backing it. Tree Item's `icon` field is a plain optional ReactNode for the same reason, not a fixed folder/file enum.

Tokens used

component/menu/item-hoversemantic/action/primarysemantic/icon/mutedsemantic/text/primarycomponent/radius/control

Component API

PropTypeDefaultDescription
dataTreeNode[]Recursive node data: { id, label, icon?, children? }. The only shape Tree View accepts.
expanded / defaultExpandedstring[]Controlled or uncontrolled list of expanded node ids.
onExpandedChange(expanded: string[]) => voidCalled whenever the expanded set changes, from click or keyboard.
selected / defaultSelectedstring | nullControlled or uncontrolled single selected node id.
onSelectedChange(id: string | null) => voidCalled when a row is selected via click, Enter, or Space.

React example

Copy React example
import { useState } from "react";
import { TreeView } from "@/components/ui";

const data = [
  {
    id: "src",
    label: "src",
    children: [
      { id: "components", label: "components", children: [
        { id: "button", label: "Button.tsx" },
      ] },
      { id: "index", label: "index.tsx" },
    ],
  },
  { id: "readme", label: "README.md" },
];

export function Example() {
  const [expanded, setExpanded] = useState<string[]>(["src"]);
  const [selected, setSelected] = useState<string | null>(null);

  return (
    <TreeView
      data={data}
      expanded={expanded}
      onExpandedChange={setExpanded}
      selected={selected}
      onSelectedChange={setSelected}
      aria-label="Project files"
    />
  );
}