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