Navigation
Tree
A nested list that opens and closes, for files, navigation or settings with a hierarchy.
- Built on
role="tree"Roving tabindexGrid track animation- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.6 kB
- Dependencies
- React
- src
- components
- button.tsx
- tree.tsx
Try its options
Loading its options.
Code
import { Tree, TreeItem } from "@/components/equip/tree";
const folder = (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 4.5c0-.6.4-1 1-1h3l1.5 1.5H13c.6 0 1 .4 1 1v6c0 .6-.4 1-1 1H3c-.6 0-1-.4-1-1z" />
</svg>
);
const file = (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 2.5h5l3 3v8H4z M9 2.5v3h3" />
</svg>
);
export default function Demo() {
return (
<Tree
label="Project files"
defaultExpanded={["src", "components"]}
defaultSelected="button"
style={{ width: 200 }}
>
<TreeItem id="src" label="src" icon={folder}>
<TreeItem id="components" label="components" icon={folder}>
<TreeItem id="button" label="button.tsx" icon={file} />
<TreeItem id="tree" label="tree.tsx" icon={file} />
</TreeItem>
</TreeItem>
</Tree>
);
}Your versionchange its options in Look, This part
Examples
Navigation with a controlled selection
Arrows only move focus; Enter or Space selects, so a long tree can be browsed without opening every page.
- Overview
- Team
- Members
- Roles
- Single sign-on
- Billing
- Invoices
- Plan and usage
Code
"use client";
import { useState } from "react";
import { Tree, TreeItem } from "@/components/equip/tree";
const pages: Record<string, string> = {
overview: "Overview",
members: "Members",
roles: "Roles",
sso: "Single sign-on",
invoices: "Invoices",
plan: "Plan and usage",
};
export default function Example() {
const [page, setPage] = useState("roles");
return (
<div style={{ display: "grid", gap: 8, width: 240 }}>
<Tree label="Settings" selected={page} onSelectedChange={setPage} defaultExpanded={["team"]}>
<TreeItem id="overview" label="Overview" />
<TreeItem id="team" label="Team">
<TreeItem id="members" label="Members" />
<TreeItem id="roles" label="Roles" />
<TreeItem id="sso" label="Single sign-on" />
</TreeItem>
<TreeItem id="billing" label="Billing">
<TreeItem id="invoices" label="Invoices" />
<TreeItem id="plan" label="Plan and usage" />
</TreeItem>
</Tree>
<span style={{ fontSize: 12, color: "var(--eq-ink-2)" }}>Showing {pages[page] ?? page}</span>
</div>
);
}Deep nesting and disabled items
Press * to open every sibling at once. A disabled item can still be reached, so people can see it exists.
- production
- eu-west-1
- api-gateway
- workers (12)
- postgres-primary
- us-east-1
- api-gateway
- postgres-replica
- ap-southeast-2 (locked)
- api-gateway
- staging
- api-gateway
Code
import { Tree, TreeItem } from "@/components/equip/tree";
export default function Example() {
return (
<Tree label="Infrastructure" defaultExpanded={["prod", "eu-west-1"]} style={{ width: 260 }}>
<TreeItem id="prod" label="production">
<TreeItem id="eu-west-1" label="eu-west-1">
<TreeItem id="api" label="api-gateway" />
<TreeItem id="workers" label="workers (12)" />
<TreeItem id="db" label="postgres-primary" />
</TreeItem>
<TreeItem id="us-east-1" label="us-east-1">
<TreeItem id="api-us" label="api-gateway" />
<TreeItem id="db-us" label="postgres-replica" />
</TreeItem>
<TreeItem id="ap" label="ap-southeast-2 (locked)" disabled>
<TreeItem id="api-ap" label="api-gateway" />
</TreeItem>
</TreeItem>
<TreeItem id="staging" label="staging">
<TreeItem id="api-staging" label="api-gateway" />
</TreeItem>
</Tree>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the tree for assistive tech, e.g. "Project files". |
selected | string | null | none | Id of the selected item, when you control it. |
defaultSelected | string | none | Id of the item selected at first, when uncontrolled. |
onSelectedChange | (id: string) => void | none | Called when a different item is selected. |
defaultExpanded | string[] | none | Ids of the items that start open. |
children | ReactNode | none | TreeItem elements. |
...rest | ComponentProps<"ul"> | none | Every native ul attribute, applied to the tree. |
TreeItem id | string | none | Identifies the item for selection and expansion. It is not used as the DOM id. |
TreeItem label | ReactNode | none | The row's text. Type-ahead matches against it. |
TreeItem icon | ReactNode | none | A 16px icon before the label. |
TreeItem children | ReactNode | none | Nested TreeItem elements. Having them makes the item expandable. |
TreeItem disabled | boolean | none | Reachable but cannot be selected, opened or closed. |
TreeItem ...rest | ComponentProps<"li"> | none | Every native li attribute. |
Anatomy
| root | .eq-tree | The ul with role="tree". |
|---|---|---|
| item | .eq-tree-item | The li with role="treeitem"; it takes focus. |
| row | .eq-tree-row | The visible line of an item; the focus ring is drawn here. |
| toggle | .eq-tree-toggle | Holds the chevron, or keeps the indent on a leaf. |
| chevron | .eq-tree-chevron | |
| icon | .eq-tree-icon | |
| label | .eq-tree-label | |
| panel | .eq-tree-panel | Animates the children open and closed. |
| group | .eq-tree-group | The ul with role="group"; its edge is the guide line. |
Keyboard
| ArrowDownArrowUp | Moves to the next or previous visible item. |
|---|---|
| ArrowRight | Opens a closed item, or moves to the first child of an open one. |
| ArrowLeft | Closes an open item, or moves to the parent. |
| HomeEnd | Moves to the first or last visible item. |
| EnterSpace | Selects the focused item. |
| * | Opens every sibling of the focused item. |
| A-Z | Moves to the next visible item whose label starts with that letter. |
Notes
- The tree is one tab stop with a roving tabindex: Tab returns to the last focused item, or the selected one.
- Focus does not select. Clicking a row selects it and opens or closes it if it has children.
- Closed children stay in the DOM but are hidden from assistive tech and the tab order once the close has played.
- If closing a branch hides the item holding the tab stop, the stop moves to the nearest visible parent.
- Only items with children get aria-expanded, and each item states its aria-level.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/tree.tsx263 lines
"use client";
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ComponentProps,
type FocusEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import "./tree.css";
export type TreeProps = Omit<ComponentProps<"ul">, "children" | "onSelect"> & {
/** Names the tree for assistive tech, e.g. "Project files". */
label: string;
/** Id of the selected item, when you control it. */
selected?: string | null;
defaultSelected?: string;
onSelectedChange?: (id: string) => void;
/** Ids of the items that start open. */
defaultExpanded?: string[];
/** TreeItem elements. */
children: ReactNode;
};
type TreeContext = {
expanded: Set<string>;
selected: string | null;
tabStop: string | null;
toggle: (id: string) => void;
select: (id: string) => void;
};
const Context = createContext<TreeContext>({
expanded: new Set(),
selected: null,
tabStop: null,
toggle: () => {},
select: () => {},
});
const Level = createContext(1);
function itemOf(node: EventTarget | null) {
return node instanceof Element ? node.closest<HTMLElement>('[role="treeitem"]') : null;
}
export function Tree({
label,
selected: selectedProp,
defaultSelected,
onSelectedChange,
defaultExpanded,
className,
children,
onKeyDown,
onFocus,
...rest
}: TreeProps) {
const ref = useRef<HTMLUListElement>(null);
const [expanded, setExpanded] = useState(() => new Set(defaultExpanded));
const [internal, setInternal] = useState<string | null>(defaultSelected ?? null);
const [focused, setFocused] = useState<string | null>(null);
const selected = selectedProp !== undefined ? selectedProp : internal;
function visible() {
return Array.from(ref.current?.querySelectorAll<HTMLElement>('[role="treeitem"]') ?? []).filter(
(item) => !item.parentElement?.closest('[role="treeitem"][aria-expanded="false"]'),
);
}
// One item is always the tab stop: the focused one, else the selected one, else the first.
// If a collapse hides it, the stop moves up to the nearest visible ancestor.
const [tabStop, setTabStop] = useState<string | null>(null);
useEffect(() => {
const items = visible();
const want = focused ?? selected;
let item = items.find((node) => node.dataset.id === want);
if (!item && want) {
const hidden = ref.current?.querySelector<HTMLElement>(`[role="treeitem"][data-id="${CSS.escape(want)}"]`);
let parent = itemOf(hidden?.parentElement ?? null);
while (parent && !items.includes(parent)) parent = itemOf(parent.parentElement);
item = parent ?? undefined;
}
setTabStop((item ?? items[0])?.dataset.id ?? null);
}, [focused, selected, expanded]);
function toggle(id: string, open?: boolean) {
setExpanded((current) => {
const next = new Set(current);
if (open ?? !next.has(id)) next.add(id);
else next.delete(id);
return next;
});
}
function select(id: string) {
if (selectedProp === undefined) setInternal(id);
if (id !== selected) onSelectedChange?.(id);
}
function handleFocus(event: FocusEvent<HTMLUListElement>) {
onFocus?.(event);
const item = itemOf(event.target);
if (item?.dataset.id) setFocused(item.dataset.id);
}
function handleKeyDown(event: KeyboardEvent<HTMLUListElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const item = itemOf(event.target);
const id = item?.dataset.id;
if (!item || id === undefined) return;
const items = visible();
const index = items.indexOf(item);
const parent = itemOf(item.parentElement);
const isParent = item.hasAttribute("aria-expanded");
const isOpen = item.getAttribute("aria-expanded") === "true";
const usable = item.getAttribute("aria-disabled") !== "true";
let next: HTMLElement | null | undefined;
switch (event.key) {
case "ArrowDown":
next = items[index + 1];
break;
case "ArrowUp":
next = items[index - 1];
break;
case "Home":
next = items[0];
break;
case "End":
next = items[items.length - 1];
break;
case "ArrowRight":
if (!isParent) break;
if (!isOpen) {
if (usable) toggle(id, true);
} else {
next = item.querySelector<HTMLElement>('[role="group"] > [role="treeitem"]');
}
event.preventDefault();
break;
case "ArrowLeft":
if (isParent && isOpen && usable) {
toggle(id, false);
event.preventDefault();
} else {
next = parent;
}
break;
case "Enter":
case " ":
event.preventDefault();
if (usable) select(id);
return;
case "*": {
const siblings = Array.from(item.parentElement?.children ?? []).filter(
(node): node is HTMLElement =>
node instanceof HTMLElement && node.getAttribute("aria-expanded") === "false" && node.getAttribute("aria-disabled") !== "true",
);
setExpanded((current) => new Set([...current, ...siblings.map((node) => node.dataset.id ?? "")]));
event.preventDefault();
return;
}
default: {
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
const letter = event.key.toLowerCase();
for (let step = 1; step <= items.length; step++) {
const candidate = items[(index + step) % items.length];
const text = candidate.querySelector(":scope > .eq-tree-row .eq-tree-label")?.textContent ?? "";
if (text.trim().toLowerCase().startsWith(letter)) {
next = candidate;
break;
}
}
}
}
if (!next) return;
event.preventDefault();
next.focus();
}
return (
<ul
{...rest}
ref={ref}
role="tree"
aria-label={label}
className={["eq-tree", className].filter(Boolean).join(" ")}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
>
<Context.Provider value={{ expanded, selected, tabStop, toggle, select }}>{children}</Context.Provider>
</ul>
);
}
export type TreeItemProps = Omit<ComponentProps<"li">, "id" | "children"> & {
/** Identifies the item for selection and expansion. Not used as the DOM id. */
id: string;
label: ReactNode;
/** An icon before the label, e.g. a folder or file glyph. */
icon?: ReactNode;
/** Nested TreeItem elements. Their presence makes the item expandable. */
children?: ReactNode;
disabled?: boolean;
};
export function TreeItem({ id, label, icon, children, disabled, className, ...rest }: TreeItemProps) {
const { expanded, selected, tabStop, toggle, select } = useContext(Context);
const level = useContext(Level);
const isParent = children != null && children !== false;
const isOpen = isParent && expanded.has(id);
return (
<li
{...rest}
role="treeitem"
className={["eq-tree-item", className].filter(Boolean).join(" ")}
data-id={id}
tabIndex={tabStop === id ? 0 : -1}
aria-level={level}
aria-expanded={isParent ? isOpen : undefined}
aria-selected={disabled ? undefined : selected === id}
aria-disabled={disabled || undefined}
>
<div
className="eq-tree-row"
onClick={() => {
if (disabled) return;
select(id);
if (isParent) toggle(id);
}}
>
<span className="eq-tree-toggle" aria-hidden="true">
{isParent && (
<svg className="eq-tree-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4l4 4-4 4" />
</svg>
)}
</span>
{icon != null && (
<span className="eq-tree-icon" aria-hidden="true">
{icon}
</span>
)}
<span className="eq-tree-label">{label}</span>
</div>
{isParent && (
<div className="eq-tree-panel">
<ul role="group" className="eq-tree-group">
<Level.Provider value={level + 1}>{children}</Level.Provider>
</ul>
</div>
)}
</li>
);
}components/equip/tree.css138 lines
@layer equip {
.eq-tree,
.eq-tree-group {
margin: 0;
padding: 0;
list-style: none;
}
.eq-tree {
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
}
/* The item itself takes focus; the ring is drawn on its row so it never wraps the children. */
.eq-tree-item:focus {
outline: none;
}
.eq-tree-item:focus-visible > .eq-tree-row {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
.eq-tree-row {
display: flex;
align-items: center;
gap: var(--eq-space-1);
box-sizing: border-box;
min-height: var(--eq-h-s);
padding-inline: var(--eq-space-1) var(--eq-space-2);
border-radius: var(--eq-radius-s);
white-space: nowrap;
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-tree-item:not([aria-disabled="true"]) > .eq-tree-row:hover {
background: var(--eq-tint);
}
}
.eq-tree-item:not([aria-disabled="true"]) > .eq-tree-row:active {
scale: var(--eq-press);
}
.eq-tree-item[aria-selected="true"] > .eq-tree-row,
.eq-tree-item[aria-selected="true"] > .eq-tree-row:hover {
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-tree-item[aria-disabled="true"] > .eq-tree-row {
opacity: 0.45;
cursor: not-allowed;
}
.eq-tree-toggle {
flex: none;
display: grid;
place-items: center;
width: 16px;
height: 16px;
}
.eq-tree-chevron {
width: 14px;
height: 14px;
color: var(--eq-ink-3);
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
.eq-tree-item[aria-selected="true"] > .eq-tree-row .eq-tree-chevron {
color: currentColor;
}
.eq-tree-item[aria-expanded="true"] > .eq-tree-row .eq-tree-chevron {
rotate: 90deg;
}
.eq-tree-icon {
flex: none;
display: grid;
place-items: center;
width: 16px;
height: 16px;
color: var(--eq-ink-2);
}
.eq-tree-item[aria-selected="true"] > .eq-tree-row .eq-tree-icon {
color: currentColor;
}
.eq-tree-icon > svg {
width: 16px;
height: 16px;
}
.eq-tree-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* Children open by growing their row track from 0fr to 1fr, which every current engine
can animate. Hidden children are invisible, so they leave the tab order and the
accessibility tree once the close has played. */
.eq-tree-panel {
display: grid;
grid-template-rows: 0fr;
visibility: hidden;
transition:
grid-template-rows var(--eq-dur) var(--eq-ease),
visibility var(--eq-dur) var(--eq-ease);
}
.eq-tree-item[aria-expanded="true"] > .eq-tree-panel {
grid-template-rows: 1fr;
/* inherit, not visible, so a closed ancestor still hides an open branch inside it. */
visibility: inherit;
transition:
grid-template-rows var(--eq-dur-move) var(--eq-spring),
visibility var(--eq-dur-move) var(--eq-ease);
}
/* The guide line sits under the parent's chevron; each level indents by 16px. */
.eq-tree-group {
min-height: 0;
overflow: hidden;
margin-inline-start: calc(var(--eq-space-1) + 7px);
padding-inline-start: var(--eq-space-1);
border-inline-start: 1px solid var(--eq-line);
}
}