Skip to content
cennge-ui
GitHubv0.3.1, changelog

Tree

Folders and files, or anything nested: open, close and select with the keys a file tree should answer to.

cennge-uimain
src
components
tree
index.ts
tree.test.tsx
tree.tsx
index.ts
styles.css
CHANGELOG.md
package.json
README.md

Opened src/components/tree/tree.tsx

  1. Arrows walk the tree: Right opens a folder or steps in, Left closes it or goes up. Enter opens a file.
  2. Type a letter to jump to the next name that starts with it; * opens every folder at that level.
  3. e2e fetches its files on the first open, with a spinner in place of the arrow.
Live: the component itself, not a picture of it.

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 expandedKeys and selectedKey, or uncontrolled with their default… 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
loadChildren on 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

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: onAction tells 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.

lazy.tsx
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.

src
index.ts
styles.css

Selected: src/index.ts

controlled.tsx
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.

dependencies
clsx ^2.1.1
radix-ui ^1.8.1
tailwind-merge ^3.7.0
tailwind-variants ^3.3.1
peerDependencies
maplibre-gl optional ^6.0.0
react ^17 || ^18 || ^19
react-dom ^17 || ^18 || ^19
tailwindcss ^4.0.0
custom-labels.tsx
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 a treeitem with aria-level, aria-posinset and aria-setsize, so screen readers say where it sits ("level 2, 3 of 4"). Name the tree with aria-label.
  • Folders carry aria-expanded, the selected item aria-selected, and a folder that is loading aria-busy. With selectionMode="none" no item is selectable and aria-selected is 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#

PropTypeDefault
itemsrequired
TreeItem[]
–
expandedKeys
string[]
–
defaultExpandedKeys
string[]
–
onExpandedChange
(keys: string[]) => void
–
selectedKey
string | null
–
defaultSelectedKey
string | null
–
onSelectionChange
(key: string | null) => void
–
selectionMode
"single""none""none" for a tree you only browse.
"single"
onAction
(item: TreeItem) => voidA file (an item without children) was opened: click or Enter.
–
loadChildren
(item: TreeItem) => Promise<TreeItem[]>Fetches the children of an item with hasChildren on its first expand.
–
onLoadError
(item: TreeItem, error: unknown) => voidCalled when loadChildren fails; the item collapses so it can be tried again.
–
hideIcons
booleanHide the default folder and file icons.
–
indent
stringIndent per level.
"1rem"
color
"default""primary""secondary""success""warning""danger""info"Colour of the selected item.
"primary"
size
"sm""md""lg"
"md"
classNames
Partial<Record<TreeSlots, string>>
–

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;
}