Skip to content

Navigation

Tree

A nested list where branches expand and collapse behind one tab stop.

When to use it: Use it for hierarchies such as file explorers, categories, or document outlines.

On this page

Example

Loading example…
Tree.tsxtsx
import { useState } from "react";
import { Tree, TreeGroup, TreeItem } from "@comp0/react";
import { DocumentIcon, FolderIcon, FolderOpenIcon } from "@heroicons/react/16/solid";

type FileNode = {
  name: string;
  children?: FileNode[];
};

const files: FileNode[] = [
  {
    name: "src",
    children: [
      {
        name: "components",
        children: [{ name: "Button.tsx" }, { name: "Input.tsx" }],
      },
      { name: "index.ts" },
    ],
  },
  { name: "README.md" },
];

function FileItem({ node, expanded }: { node: FileNode; expanded: string[] }) {
  let Glyph = DocumentIcon;
  if (node.children) {
    Glyph = FolderIcon;
    if (expanded.includes(node.name)) Glyph = FolderOpenIcon;
  }

  return (
    <TreeItem value={node.name} textValue={node.name} className="grid gap-0.5 outline-none">
      <span className="flex cursor-pointer items-center gap-1.5 rounded px-2 py-1.5 text-base text-zinc-800 sm:py-1 sm:text-sm dark:text-zinc-100 [[data-selected]>&]:bg-teal-100 [[data-selected]>&]:text-teal-950 dark:[[data-selected]>&]:bg-teal-950 dark:[[data-selected]>&]:text-teal-50 [[role=treeitem]:focus-visible>&]:outline-2 [[role=treeitem]:focus-visible>&]:outline-teal-600 dark:[[role=treeitem]:focus-visible>&]:outline-teal-400">
        <Glyph className="size-4 shrink-0 text-zinc-400 dark:text-zinc-500" aria-hidden="true" />
        {node.name}
      </span>
      {node.children && (
        <TreeGroup className="grid gap-0.5 pl-5">
          {node.children.map((child) => (
            <FileItem key={child.name} node={child} expanded={expanded} />
          ))}
        </TreeGroup>
      )}
    </TreeItem>
  );
}

export function Example() {
  const [selected, setSelected] = useState("README.md");
  const [expanded, setExpanded] = useState(["src"]);

  return (
    <div className="flex max-w-xs flex-col gap-2">
      <Tree
        aria-label="Project files"
        className="grid gap-0.5 rounded border border-zinc-950/10 p-1 dark:border-white/10"
        value={selected}
        onChange={setSelected}
        expanded={expanded}
        onExpandedChange={setExpanded}
      >
        {files.map((node) => (
          <FileItem key={node.name} node={node} expanded={expanded} />
        ))}
      </Tree>
      <p className="text-base text-zinc-600 sm:text-sm dark:text-zinc-400">Selected: {selected}</p>
    </div>
  );
}

Live execution tree

Show nested work as an arrow-key tree while a progress bar and polite status report meaningful workflow transitions instead of every low-level update.

Loading example…
tree.activity.tsxtsx
import { useEffect, useState } from "react";
import { Button, ProgressBar, Status, Tree, TreeGroup, TreeItem } from "@comp0/react";

type TaskStatus = "waiting" | "running" | "complete";

type ActivityTask = {
  value: string;
  label: string;
  statuses: readonly TaskStatus[];
  children?: readonly ActivityTask[];
};

const workflow: readonly ActivityTask[] = [
  {
    value: "release",
    label: "Release documentation",
    statuses: ["waiting", "running", "running", "running", "running", "complete"],
    children: [
      {
        value: "build",
        label: "Build packages",
        statuses: ["waiting", "running", "complete", "complete", "complete", "complete"],
      },
      {
        value: "verify",
        label: "Run verification",
        statuses: ["waiting", "waiting", "running", "running", "complete", "complete"],
        children: [
          {
            value: "unit",
            label: "Unit tests",
            statuses: ["waiting", "waiting", "running", "complete", "complete", "complete"],
          },
          {
            value: "browser",
            label: "Browser tests",
            statuses: ["waiting", "waiting", "waiting", "running", "complete", "complete"],
          },
        ],
      },
      {
        value: "publish",
        label: "Publish release",
        statuses: ["waiting", "waiting", "waiting", "waiting", "running", "complete"],
      },
    ],
  },
];

const statusClasses: Record<TaskStatus, string> = {
  waiting: "bg-zinc-300 dark:bg-zinc-600",
  running: "bg-amber-500 ring-4 ring-amber-500/15",
  complete: "bg-emerald-600 dark:bg-emerald-400",
};

const announcements = [
  "Workflow ready.",
  "Building packages.",
  "Packages built. Running unit tests.",
  "Unit tests passed. Running browser tests.",
  "Verification passed. Publishing release.",
  "Release published successfully.",
] as const;

function ActivityTaskRow({ task, step }: { task: ActivityTask; step: number }) {
  const status = task.statuses[step] ?? "waiting";
  return (
    <TreeItem
      value={task.value}
      textValue={`${task.label}, ${status}`}
      aria-current={status === "running" ? "step" : undefined}
      className="grid gap-1 outline-none"
    >
      <span className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-zinc-800 [[role=treeitem]:focus-visible>&]:outline-2 [[role=treeitem]:focus-visible>&]:outline-teal-600 [[data-selected]>&]:bg-zinc-100 dark:text-zinc-100 dark:[[role=treeitem]:focus-visible>&]:outline-teal-400 dark:[[data-selected]>&]:bg-zinc-800">
        <span aria-hidden="true" className={`size-2 rounded-full ${statusClasses[status]}`} />
        <span className="flex-1">{task.label}</span>
        <span className="text-xs capitalize text-zinc-600 dark:text-zinc-400">{status}</span>
      </span>
      {task.children && (
        <TreeGroup className="grid gap-1 pl-5">
          {task.children.map((child) => (
            <ActivityTaskRow key={child.value} task={child} step={step} />
          ))}
        </TreeGroup>
      )}
    </TreeItem>
  );
}

export function Example() {
  const [step, setStep] = useState(0);
  const [running, setRunning] = useState(false);
  const [selected, setSelected] = useState("release");

  useEffect(() => {
    if (!running) return;
    if (step >= announcements.length - 1) {
      setRunning(false);
      return;
    }
    const timer = setTimeout(() => setStep((current) => current + 1), 650);
    return () => clearTimeout(timer);
  }, [running, step]);

  const startWorkflow = () => {
    setStep(0);
    setRunning(true);
  };

  return (
    <section aria-labelledby="activity-title" className="w-full max-w-md">
      <div className="flex items-start justify-between gap-4">
        <div>
          <h2 id="activity-title" className="font-semibold text-zinc-950 dark:text-white">
            Release activity
          </h2>
          <Status className="mt-1 text-sm text-zinc-600 dark:text-zinc-400" aria-atomic="true">
            {announcements[step]}
          </Status>
        </div>
        <Button
          disabled={running}
          className="shrink-0 rounded-lg bg-teal-700 px-3 py-2 text-sm font-medium text-white outline-teal-600 focus-visible:outline-2 disabled:opacity-50 dark:bg-teal-400 dark:text-zinc-950 dark:outline-teal-300"
          onClick={startWorkflow}
        >
          {step === 0 ? "Run workflow" : "Run again"}
        </Button>
      </div>
      <ProgressBar
        value={step}
        max={announcements.length - 1}
        aria-label="Release progress"
        className="mt-4 h-1.5 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800"
      >
        <span className="block h-full w-[calc(var(--comp0-progress-value)*100%)] rounded-full bg-teal-700 transition-[width] motion-reduce:transition-none dark:bg-teal-400" />
      </ProgressBar>
      <Tree
        aria-labelledby="activity-title"
        value={selected}
        onChange={setSelected}
        defaultExpanded={["release", "verify"]}
        className="mt-4 grid gap-1 rounded-xl border border-zinc-950/10 bg-white p-2 dark:border-white/10 dark:bg-zinc-900"
      >
        {workflow.map((task) => (
          <ActivityTaskRow key={task.value} task={task} step={step} />
        ))}
      </Tree>
    </section>
  );
}

Anatomy

Dashed frames are invisible state providers; shaded shapes own real DOM. Numbered pins match the list below.

A wireframe sketch of the assembled component. Each numbered marker matches a part in the list that follows.

  1. Tree

    Hierarchy container and the tree's single tab stop; arrow keys walk visible rows. Owns a DOM element.

  2. TreeGroupOptional

    Container for one item's children; it gets the hidden attribute while its parent is collapsed. Owns a DOM element.

  3. TreeItem

    Selectable row; nesting a TreeGroup inside makes it an expandable branch. Owns a DOM element.

Step by step

  1. 1

    Add the main part

    Wrap top-level TreeItems in Tree and give it an aria-label.

  2. 2

    Add the supporting parts

    Nest a TreeGroup of child TreeItems inside an item to make it an expandable branch.

  3. 3

    Make the behavior clear

    Read the selection from onChange; pass defaultExpanded, or expanded with onExpandedChange, to manage the open branches. Clicking an expandable row selects it and toggles its branch.

    Exampletsx
    <Tree aria-label="Files" defaultExpanded={["src"]}>
      <TreeItem value="src">
        src
        <TreeGroup>
          <TreeItem value="index">index.ts</TreeItem>
        </TreeGroup>
      </TreeItem>
    </Tree>;

Keyboard

⇥
Moves into the tree to the active item; Tab again leaves.
↓
Moves to the next visible item without wrapping.
↑
Moves to the previous visible item without wrapping.
←→
Inline-forward expands or enters a branch; inline-backward collapses or reaches its parent. Physical keys reverse in RTL.
Home
Moves to the first visible item.
End
Moves to the last visible item.
↵
Selects the focused item.
Space
Selects the focused item.

Forms and accessibility

Selection does not create a native form value; mirror it into a hidden input when a form needs it.

Accessibility checklist

  • Give the tree an aria-label that names the hierarchy, such as Project files.
  • Levels, positions, and expansion state are announced automatically; keep each row's visible text meaningful on its own and pass textValue when markup obscures it.
  • Clicking an expandable row both selects it and toggles its branch. The inline-forward arrow expands and inline-backward collapses, so the physical keys reverse in RTL; avoid extra click targets inside rows.

API reference

Importtsx
import { Tree, TreeGroup, TreeItem } from "@comp0/react";

Tree

DOM element

Hierarchy container and the tree's single tab stop; arrow keys walk visible rows.

PropTypeDescription
aria-labelstringNames the tree; nothing labels it automatically.
valuestringControlled selected item.
defaultValuestringInitial selected item.
onChange(value: string) => voidReceives the next selected item's value.
expandedstring[]Controlled expanded items.
defaultExpandedstring[]Initial expanded items.
onExpandedChange(expanded: string[]) => voidReceives the next expanded item values.

TreeGroup

OptionalDOM element

Container for one item's children; it gets the hidden attribute while its parent is collapsed.

PropTypeDescription

TreeItem

DOM element

Selectable row; nesting a TreeGroup inside makes it an expandable branch.

PropTypeDescription
valuestringThis item's selection and expansion key.
disabledbooleanDisables the item and removes it from the arrow-key order.
textValuestringOverrides the text crawled from the row for typeahead when markup makes it ambiguous.

Style hooks

Attributes that appear while a state is true. Target them with Tailwind data variants such as data-open:bg-zinc-100, or with any CSS selector.

TreeItem

Style hookMeaning
[data-selected]The item is selected.
[data-open]The item's branch is open.
[data-disabled]The item is disabled.
:focus-visibleThe item has visible keyboard focus.

Keep exploring