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.
Example
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.
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.
Tree
Hierarchy container and the tree's single tab stop; arrow keys walk visible rows. Owns a DOM element.
TreeGroupOptional
Container for one item's children; it gets the hidden attribute while its parent is collapsed. Owns a DOM element.
TreeItem
Selectable row; nesting a TreeGroup inside makes it an expandable branch. Owns a DOM element.
Step by step
- 1
Add the main part
Wrap top-level TreeItems in Tree and give it an aria-label.
- 2
Add the supporting parts
Nest a TreeGroup of child TreeItems inside an item to make it an expandable branch.
- 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
import { Tree, TreeGroup, TreeItem } from "@comp0/react";Tree
DOM elementHierarchy container and the tree's single tab stop; arrow keys walk visible rows.
| Prop | Type | Description |
|---|---|---|
aria-label | string | Names the tree; nothing labels it automatically. |
value | string | Controlled selected item. |
defaultValue | string | Initial selected item. |
onChange | (value: string) => void | Receives the next selected item's value. |
expanded | string[] | Controlled expanded items. |
defaultExpanded | string[] | Initial expanded items. |
onExpandedChange | (expanded: string[]) => void | Receives the next expanded item values. |
TreeGroup
OptionalDOM elementContainer for one item's children; it gets the hidden attribute while its parent is collapsed.
| Prop | Type | Description |
|---|
TreeItem
DOM elementSelectable row; nesting a TreeGroup inside makes it an expandable branch.
| Prop | Type | Description |
|---|---|---|
value | string | This item's selection and expansion key. |
disabled | boolean | Disables the item and removes it from the arrow-key order. |
textValue | string | Overrides 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 hook | Meaning |
|---|---|
[data-selected] | The item is selected. |
[data-open] | The item's branch is open. |
[data-disabled] | The item is disabled. |
:focus-visible | The item has visible keyboard focus. |