Tree
Folders and files, or anything nested: open, close and select with the keys a file tree should answer to.
Opened src/components/tree/tree.tsx
- Arrows walk the tree: Right opens a folder or steps in, Left closes it or goes up. Enter opens a file.
- Type a letter to jump to the next name that starts with it; * opens every folder at that level.
- e2e fetches its files on the first open, with a spinner in place of the arrow.
Field marks
- Import
import { Tree } from "cennge-ui";- Size
- 3.3 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA tree view, single selection
- Keyboard
- Arrow up Arrow down Arrow left Arrow right HomeEndEnter Space*
- State
- Controlled with
expandedKeysandselectedKey, or uncontrolled with theirdefault…props - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Type-ahead
- Letters jump to the next item whose name starts with them
- Lazy children
loadChildrenon the first open; a failed load collapses for a retry- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: tree
- Source
- src/components/tree
When to use#
Use a Tree for a hierarchy people move through and pick from: a repository's files, folders of documents, nested categories. It is one Tab stop; from there the arrow keys do the walking, so a long tree costs a keyboard user one key press to pass.
Items are plain objects with an id, a label and children. A folder whose contents are not known yet gets hasChildren, and loadChildren fetches them the first time it opens.
Similar#
- Accordion opens and closes sections of content; a Tree opens folders of items you select.
- Sidebar holds an app's navigation as links. A Tree's items are not links:
onActiontells you which file was opened. - Table is for flat records with columns.
Children on demand#
loadChildren runs the first time an item with hasChildren opens, and the item is busy until it resolves. If it rejects, onLoadError gets the item and the reason, and the item closes so the next open tries again. Open payload-cache: its first request fails here on purpose.
import { useRef, useState } from "react";
import { Tree, type TreeItem } from "cennge-ui";
const branches: Record<string, string[]> = {
"cennge-ui": ["main", "docs/table"],
"ai-niche-scout": ["main", "feat/keyword-score"],
"payload-cache": ["main", "fix/ttl"],
"spredo-crypto-screener": ["main", "feat/funding-rates"],
"playwright-msi-scraper": ["main", "feat/retries"],
};
const repositories: TreeItem[] = Object.keys(branches).map((name) => ({ id: name, label: name, hasChildren: true }));
function BranchIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="6" cy="5.5" r="2.5" />
<circle cx="6" cy="18.5" r="2.5" />
<circle cx="18" cy="7.5" r="2.5" />
<path d="M6 8v8M18 10c0 4-6 3.5-11 7" strokeLinecap="round" />
</svg>
);
}
export default function RepositoryBranches() {
const [error, setError] = useState<string | null>(null);
const attempts = useRef(new Map<string, number>());
// Stands in for a request: payload-cache fails the first time, as a flaky network would.
const loadChildren = (repository: TreeItem) =>
new Promise<TreeItem[]>((resolve, reject) => {
const attempt = (attempts.current.get(repository.id) ?? 0) + 1;
attempts.current.set(repository.id, attempt);
setError(null);
setTimeout(() => {
if (repository.id === "payload-cache" && attempt === 1) reject(new Error("The request timed out."));
else
resolve(
(branches[repository.id] ?? []).map((branch) => ({
id: `${repository.id}:${branch}`,
label: branch,
icon: <BranchIcon />,
})),
);
}, 800);
});
return (
<div className="flex w-full max-w-80 flex-col gap-2">
<Tree
aria-label="Repositories and their branches"
items={repositories}
loadChildren={loadChildren}
onLoadError={(repository, reason) =>
setError(`${repository.id}: ${reason instanceof Error ? reason.message : "Could not load."} Open it again to retry.`)
}
className="rounded-large bg-surface-3 p-1.5"
/>
<p role="status" className="min-h-10 text-sm text-danger-text">
{error}
</p>
</div>
);
}Controlled#
expandedKeys and selectedKey, with their on…Change callbacks, put the state in your hands: open every folder from a button, or keep the selection in the URL. A click on a folder selects it and opens or closes it; a click on its arrow only opens or closes.
Selected: src/index.ts
import { useState } from "react";
import { Button, Tree, type TreeItem } from "cennge-ui";
const files: TreeItem[] = [
{
id: "e2e",
label: "e2e",
children: [
{ id: "e2e/table.spec.ts", label: "table.spec.ts" },
{ id: "e2e/tree.spec.ts", label: "tree.spec.ts" },
],
},
{
id: "playground",
label: "playground",
children: [
{
id: "playground/fixtures",
label: "fixtures",
children: [
{ id: "playground/fixtures/table.tsx", label: "table.tsx" },
{ id: "playground/fixtures/tree.tsx", label: "tree.tsx" },
],
},
],
},
{
id: "src",
label: "src",
children: [
{ id: "src/index.ts", label: "index.ts" },
{ id: "src/styles.css", label: "styles.css" },
],
},
];
/** Every item that has children, at any depth. */
const folderIds = (items: TreeItem[]): string[] =>
items.flatMap((item) => (item.children ? [item.id, ...folderIds(item.children)] : []));
export default function ControlledTree() {
const [expanded, setExpanded] = useState<string[]>(["src"]);
const [selected, setSelected] = useState<string | null>("src/index.ts");
return (
<div className="flex w-full max-w-80 flex-col gap-3">
<div className="flex gap-2">
<Button size="sm" variant="flat" onClick={() => setExpanded(folderIds(files))}>
Expand all
</Button>
<Button size="sm" variant="flat" onClick={() => setExpanded([])}>
Collapse all
</Button>
</div>
<Tree
aria-label="Files"
items={files}
expandedKeys={expanded}
onExpandedChange={setExpanded}
selectedKey={selected}
onSelectionChange={setSelected}
className="rounded-large bg-surface-3 p-1.5"
/>
<p className="text-sm text-muted">
Selected: <span className="font-mono text-foreground">{selected ?? "nothing"}</span>
</p>
</div>
);
}Icons and labels of your own#
icon replaces the folder and file icons, and label takes any node. When it is not a string, give the item a textValue, so typing still finds it. Focus the tree and type t, then t again after a pause: each goes to the next name that starts with it. Letters typed within half a second of each other search as one word.
import { Badge, Tree, type TreeItem } from "cennge-ui";
function PackageIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 3 4 7.5v9L12 21l8-4.5v-9L12 3ZM4 7.5l8 4.5 8-4.5M12 12v9" strokeLinejoin="round" />
</svg>
);
}
function pkg(name: string, range: string, optional = false): TreeItem {
return {
id: name,
textValue: name,
icon: <PackageIcon />,
label: (
<span className="flex items-center gap-2">
{name} {optional ? <Badge size="sm">optional</Badge> : null}{" "}
<span className="ml-auto font-mono text-xs text-muted">{range}</span>
</span>
),
};
}
const dependencies: TreeItem[] = [
{
id: "dependencies",
label: "dependencies",
children: [
pkg("clsx", "^2.1.1"),
pkg("radix-ui", "^1.8.1"),
pkg("tailwind-merge", "^3.7.0"),
pkg("tailwind-variants", "^3.3.1"),
],
},
{
id: "peerDependencies",
label: "peerDependencies",
children: [
pkg("maplibre-gl", "^6.0.0", true),
pkg("react", "^17 || ^18 || ^19"),
pkg("react-dom", "^17 || ^18 || ^19"),
pkg("tailwindcss", "^4.0.0"),
],
},
];
export default function DependencyTree() {
return (
<Tree
aria-label="Dependencies of cennge-ui"
items={dependencies}
defaultExpandedKeys={["dependencies", "peerDependencies"]}
classNames={{ label: "flex-1" }}
className="w-full max-w-96 rounded-large bg-surface-3 p-1.5"
/>
);
}Accessibility#
- The tree is
role="tree"and each row atreeitemwitharia-level,aria-posinsetandaria-setsize, so screen readers say where it sits ("level 2, 3 of 4"). Name the tree witharia-label. - Folders carry
aria-expanded, the selected itemaria-selected, and a folder that is loadingaria-busy. WithselectionMode="none"no item is selectable andaria-selectedis left out. - Only one item is in the tab order: the one last focused, else the selected one, else the first. Closing a folder that holds the focus moves it to the folder.
- Disabled items stay in the walk for screen readers, with
aria-disabled, but do not open, close or select.
Styling#
classNames reaches each part: base (the tree), item (a row), chevron, icon and label. indent sets the step per level, 1rem by default; size sets the row height and color the selected row's tint, primary by default.
Props#
Tree#
Also takes every attribute of <div>, except onSelect, which mean something else here.
TreeItem#
The shape of each entry in items.
interface TreeItem {
id: string; // unique within the tree
label: React.ReactNode;
textValue?: string; // for type-ahead when label is not a string
icon?: React.ReactNode; // replaces the folder or file icon
children?: TreeItem[];
hasChildren?: boolean; // loadChildren fetches them on the first open
isDisabled?: boolean;
}