A hierarchy the user expands, walks with the keys and optionally ticks (also called a tree or outline). Reach for it for folders, teams and cost-centre structures; large trees stay quick because only the visible rows are drawn.
import { TreeView, TreeSearch, useTree } from "@umriss-ui/core";
useTree takes your data and a reader that says how to get a key, a label and the children; the tree owns only the view state over your data.
The active node, where the keys are, and the checked nodes are two states, never one. A tick cascades down, a partly ticked branch shows as mixed, and a disabled node stays visible and walkable but is never ticked (ADR-0003, ADR-0005).
Loading is yours: an unloaded branch reports that it was opened and shows that it is loading, and cascades and searches leave it alone until your data brings its children.
Hand your data and a reader to useTree; the reader says how to get a node's key, label and children.
import { TreeView, useTree } from "@umriss-ui/core";
import type { NodeReader } from "@umriss-ui/core";
interface Unit {
id: string;
name: string;
children?: Unit[];
}
const COMPANY: Unit[] = [
{
id: "commercial",
name: "Commercial",
children: [
{ id: "CC-1100", name: "Sales, CC-1100" },
{ id: "CC-1200", name: "Marketing, CC-1200" },
],
},
{
id: "product",
name: "Product",
children: [
{ id: "CC-2100", name: "Engineering, CC-2100" },
{ id: "CC-2200", name: "Design, CC-2200" },
],
},
{ id: "CC-3100", name: "Customer service, CC-3100" },
];
const READER: NodeReader<Unit> = {
key: (unit) => unit.id,
children: (unit) => unit.children,
label: (unit) => unit.name,
};
export default function ATree() {
const tree = useTree(COMPANY, { reader: READER, defaultExpanded: ["commercial"], defaultActive: "CC-1200" });
return (
<TreeView tree={tree} ariaLabel="Cost centres">
{(entry) => entry.node.name}
</TreeView>
);
}
Set checkable and keep the checked keys; a tick cascades down, and a partly ticked team shows as mixed.
import { useState } from "react";
import { Stack, Text, TreeView, useTree } from "@umriss-ui/core";
import type { NodeReader } from "@umriss-ui/core";
interface Member {
id: string;
name: string;
children?: Member[];
}
const TEAMS: Member[] = [
{
id: "web",
name: "Web",
children: [
{ id: "arjun", name: "Arjun Mehta" },
{ id: "chloe", name: "Chloe Durand" },
{ id: "noah", name: "Noah Fischer" },
],
},
{
id: "apps",
name: "Apps",
children: [
{ id: "hana", name: "Hana Sato" },
{ id: "kofi", name: "Kofi Mensah" },
],
},
];
const READER: NodeReader<Member> = { key: (m) => m.id, children: (m) => m.children, label: (m) => m.name };
export default function TickASelection() {
const [checked, setChecked] = useState<ReadonlySet<string>>(() => new Set(["arjun", "chloe"]));
const tree = useTree(TEAMS, { reader: READER, checked, onChecked: setChecked, defaultExpanded: ["web"] });
return (
<Stack gap={2}>
<TreeView tree={tree} ariaLabel="People for sprint 15" checkable>
{(entry) => entry.node.name}
</TreeView>
<Text size="xs" tone="muted">
Checked: {[...checked].sort().join(", ") || "nobody"}
</Text>
</Stack>
);
}
Mark a branch unloaded in the reader and fetch in onLoadChildren; the branch shows it is loading until your data brings its children.
import { useState } from "react";
import { TreeView, useTree } from "@umriss-ui/core";
import type { NodeReader } from "@umriss-ui/core";
interface Place {
id: string;
name: string;
children?: Place[];
unloaded?: boolean;
}
const DEPOTS: Place[] = [
{ id: "north", name: "North depot", unloaded: true },
{ id: "river", name: "Riverside depot", unloaded: true },
{ id: "east", name: "East Gate depot", unloaded: true },
];
/** What the server would answer, after a while. */
const TOURS: Record<string, string[]> = {
north: ["T-01 · FP 214 K", "T-02 · FP 377 K", "T-03 · FP 118 R"],
river: ["T-04 · FP 402 R", "T-05 · FP 455 R"],
east: ["T-06 · FP 290 E", "T-07 · FP 311 E", "T-08 · FP 520 E"],
};
const READER: NodeReader<Place> = {
key: (p) => p.id,
children: (p) => p.children,
label: (p) => p.name,
unloaded: (p) => p.unloaded === true,
};
export default function LoadBranchesOnDemand() {
const [depots, setDepots] = useState(DEPOTS);
const load = (depot: Place) => {
window.setTimeout(() => {
setDepots((previous) =>
previous.map((p) =>
p.id === depot.id
? { ...p, unloaded: false, children: TOURS[p.id]!.map((name) => ({ id: `${p.id}/${name}`, name })) }
: p,
),
);
}, 800);
};
const tree = useTree(depots, { reader: READER, onLoadChildren: load });
return (
<TreeView tree={tree} ariaLabel="Today's tours by depot">
{(entry) => entry.node.name}
</TreeView>
);
}
Put a TreeSearch beside the tree: each find keeps the dimmed path above it, and a search without finds says so.
import { useState } from "react";
import { Stack, TreeSearch, TreeView, useTree } from "@umriss-ui/core";
import type { NodeReader } from "@umriss-ui/core";
/* Data from the operations world, written out here so the example runs on its own. */
interface Service {
id: string;
name: string;
team: string;
/** 1 is customer-facing and pages at night; 3 waits for the morning. */
tier: 1 | 2 | 3;
/** The latency objective: the 95th percentile stays below this, in ms. */
latencySlo: number;
/** The availability promised for a month, in per cent. */
availabilityTarget: number;
}
const SERVICES: readonly Service[] = [
{ id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
{ id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
{ id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
{ id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
{ id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
{ id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
{ id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
{ id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];
interface Node {
id: string;
name: string;
children?: Node[];
}
const TEAMS: Node[] = [...new Set(SERVICES.map((service) => service.team))].map((team) => ({
id: team,
name: team,
children: SERVICES.filter((service) => service.team === team).map((service) => ({ id: service.id, name: service.name })),
}));
const READER: NodeReader<Node> = { key: (n) => n.id, children: (n) => n.children, label: (n) => n.name };
export default function SearchWithThePath() {
const [term, setTerm] = useState("check");
const tree = useTree(TEAMS, { reader: READER, search: term, onSearch: setTerm });
return (
<Stack gap={2} style={{ maxWidth: 320 }}>
<TreeSearch tree={tree} aria-label="Search the services" size="sm" />
<TreeView tree={tree} ariaLabel="Services by team">
{(entry) => entry.node.name}
</TreeView>
</Stack>
);
}
A FormField with an error marks the TreeSearch invalid by itself. Without one, set invalid and tie your message to the field with aria-describedby. Tick a service to clear it.
import { useId, useState } from "react";
import { Stack, Text, TreeSearch, TreeView, useTree } from "@umriss-ui/core";
import type { NodeReader } from "@umriss-ui/core";
interface Node {
id: string;
name: string;
children?: Node[];
}
const TEAMS: Node[] = [
{
id: "payments",
name: "Payments",
children: [
{ id: "checkout", name: "Checkout" },
{ id: "billing", name: "Billing" },
],
},
{
id: "discovery",
name: "Discovery",
children: [
{ id: "search", name: "Search" },
{ id: "images", name: "Image service" },
],
},
];
const READER: NodeReader<Node> = { key: (n) => n.id, children: (n) => n.children, label: (n) => n.name };
export default function WithAnError() {
const [checked, setChecked] = useState<ReadonlySet<string>>(() => new Set());
const tree = useTree(TEAMS, { reader: READER, checked, onChecked: setChecked, defaultExpanded: ["payments"] });
const messageId = useId();
const error = checked.size === 0 ? "Tick at least one service, or the alert rule watches nothing." : undefined;
return (
<Stack gap={2} style={{ maxWidth: 360 }}>
<Stack gap={1}>
<TreeSearch
tree={tree}
size="sm"
aria-label="Search the services the alert rule watches"
invalid={error !== undefined}
aria-describedby={error ? messageId : undefined}
/>
{error && (
<Text id={messageId} size="xs" style={{ color: "var(--u-color-danger-text)" }}>
{error}
</Text>
)}
</Stack>
<TreeView tree={tree} ariaLabel="Services by team" checkable>
{(entry) => entry.node.name}
</TreeView>
</Stack>
);
}
In a company's filing, checks cascade, a search keeps the path, locked years stay unchecked, and revealNode finds any of 240 nodes.
/* Four trees over one filing, because the component's parts are one
behaviour: the keys run on the flat list, and virtualisation shows only a
window of it. The active node and the checks are two states (ADR-0003); the
tree with checks and the one with an active node only show both.
`data-role` is a selector the interaction suite reads. */
import { useMemo, useState } from "react";
import { Button, Grid, Stack, Text, TreeSearch, TreeView, useTree } from "@umriss-ui/core";
import type { NodeReader } from "@umriss-ui/core";
interface TreeNode {
id: string;
name: string;
children?: TreeNode[];
/** May be seen but not chosen - a closed financial year, say. */
locked?: boolean;
/** The children have not been loaded yet. */
unloaded?: boolean;
}
const FILING: TreeNode[] = [
{
id: "contracts",
name: "Contracts",
children: [
{
id: "framework",
name: "Framework contracts",
children: [
{ id: "fc-brandlow", name: "Brandlow Office Supply" },
{ id: "fc-nimbrel", name: "Nimbrel Software" },
{ id: "fc-corrin", name: "Corrin Travel" },
],
},
{
id: "single",
name: "Single orders",
children: [
{ id: "so-4711", name: "Order 4711" },
{ id: "so-4712", name: "Order 4712" },
],
},
{ id: "terminations", name: "Terminations", children: [] },
{ id: "external", name: "External mandates", locked: true },
],
},
{
id: "documents",
name: "Documents",
children: [
{
id: "2026",
name: "2026",
children: [
{ id: "d-q1", name: "Quarter 1" },
{ id: "d-q2", name: "Quarter 2" },
],
},
{ id: "2025", name: "2025", children: [{ id: "d-2025", name: "Annual accounts" }] },
{ id: "2024", name: "2024 (closed)", locked: true },
],
},
{ id: "statutes", name: "Articles of association.pdf" },
{ id: "inbox", name: "Inbox", unloaded: true },
];
/* A large body for the reach: large enough that only a window of it stands in
the document, and deep enough that "reveal" has to expand something. */
const MANY: TreeNode[] = Array.from({ length: 40 }, (_, i) => ({
id: `f${i}`,
name: `Folder ${i}`,
children: Array.from({ length: 5 }, (_, k) => ({ id: `f${i}-${k}`, name: `Sheet ${i}.${k}` })),
}));
const READER: NodeReader<TreeNode> = {
key: (e) => e.id,
children: (e) => e.children,
label: (e) => e.name,
disabled: (e) => e.locked === true,
unloaded: (e) => e.unloaded === true,
};
export default function SearchCheckAndReveal() {
/* All three framework contracts: the branch above them is thereby fully
checked, the branch above that only partly. Both states at a glance. */
/* Loading belongs to the caller: the tree reports only that an unloaded
branch was opened. Here a delay is simulated and the own copy of the data
is then extended - the tree re-renders, and `unloaded` falls away with the
data. */
const [filing, setFiling] = useState<TreeNode[]>(FILING);
const load = (node: TreeNode) => {
if (node.id !== "inbox") return;
window.setTimeout(() => {
setFiling((previous) =>
previous.map((n) =>
n.id === "inbox"
? {
...n,
unloaded: false,
children: [
{ id: "in-1", name: "Invoice INV-26-0318" },
{ id: "in-2", name: "Query from Brandlow Office Supply" },
{ id: "in-3", name: "Reminder from Fenwright Legal" },
],
}
: n,
),
);
}, 700);
};
const [checked, setChecked] = useState<ReadonlySet<string>>(
() => new Set(["fc-brandlow", "fc-nimbrel", "fc-corrin", "framework"]),
);
const withChecks = useTree(filing, {
reader: READER,
checked,
onChecked: setChecked,
onLoadChildren: load,
defaultExpanded: ["contracts", "framework", "documents"],
defaultActive: "fc-nimbrel",
});
const activeOnly = useTree(filing, {
reader: READER,
defaultExpanded: ["documents", "2026"],
defaultActive: "d-q2",
});
/* The same body, narrowed by a term: the find appears with the path that
leads to it, and the path is dimmed. */
const [term, setTerm] = useState("quarter");
const searched = useTree(filing, {
reader: READER,
search: term,
onSearch: setTerm,
});
const chosen = useMemo(() => [...checked].sort().join(", "), [checked]);
const large = useTree(MANY, { reader: READER, virtual: { rowHeight: 26 } });
const [last, setLast] = useState("-");
return (
<Stack gap={4}>
<Grid minItemWidth="260px" gap={4}>
<Stack gap={2}>
<Text size="xs" tone="muted">
With checks and a search
</Text>
<TreeSearch tree={withChecks} aria-label="Search the filing" size="sm" />
<TreeView tree={withChecks} ariaLabel="Filing with a selection" checkable>
{(e) => e.node.name}
</TreeView>
<Text size="xs" tone="muted">
Checked: {chosen || "nothing"}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" tone="muted">
A search with the path
</Text>
<TreeSearch tree={searched} aria-label="Search the filing" size="sm" />
<TreeView tree={searched} ariaLabel="Filing, searched">
{(e) => e.node.name}
</TreeView>
</Stack>
<Stack gap={2}>
<Text size="xs" tone="muted">
Reach: 40 folders of 5 sheets each, virtualised
</Text>
<Stack direction="row" gap={2} wrap>
<Button size="sm" variant="ghost" onClick={() => large.expandAll()}>
Expand all
</Button>
<Button size="sm" variant="ghost" onClick={() => large.collapseAll()}>
Collapse all
</Button>
<Button
size="sm"
variant="ghost"
data-role="reveal-deep"
onClick={() => setLast(String(large.revealNode("f33-2") ?? "not found"))}
>
Reveal sheet 33.2
</Button>
</Stack>
<TreeView tree={large} ariaLabel="Large body" style={{ height: 240 }}>
{(e) => e.node.name}
</TreeView>
<Text size="xs" tone="muted">
Last revealed: {last}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" tone="muted">
Active node only
</Text>
<TreeView tree={activeOnly} ariaLabel="Filing for navigation">
{(e) => e.node.name}
</TreeView>
<Text size="xs" tone="muted">
Active: {activeOnly.active ?? "nothing"}
</Text>
</Stack>
</Grid>
</Stack>
);
}
Table with grouping from @umriss-ui/table| Key | Action |
|---|---|
Arrow Down / Arrow Up |
Moves to the next or previous visible node. |
Arrow Right |
Expands the node; on an expanded one, moves to its first child. |
Arrow Left |
Collapses the node; on a collapsed one, moves to its parent. |
Home / End |
Moves to the first or the last visible node. |
Enter |
Activates the node. |
Space |
With checks: ticks or unticks the node. |
Shift + Space |
With checks: ticks from the last ticked node to this one. |
Shift + Arrow Down / Up |
With checks: moves and ticks in one gesture. |
Ctrl + A |
With checks: ticks everything, or nothing if everything is ticked. |
A letter |
Moves to the next node whose label starts with what was typed. |
TreeViewProps<K, S extends Key = string>| Name | Type | Default | Description |
|---|---|---|---|
treerequired | Tree<K, S> | — | The tree from useTree. It is the whole state: flattening, active node,
checks, anchor. The view holds nothing beside it.Shown in: A tree, Tick a selection, Load branches on demand and 5 more |
checkable | boolean | false | Show ticks. Without them a tree remains that knows only an active node -
and no space is kept free for them. Shown in: Tick a selection, With an error, Search, check and reveal in a large tree |
childrenrequired | (entry: FlatteningEntry<K, S>) => ReactNode | — | What the node says. Shown in: A tree, Tick a selection, Load branches on demand and 5 more |
ref | Ref<HTMLDivElement> | — | Receives the component's root element. from RefPropShown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more |
| Name | Type | Default | Description |
|---|---|---|---|
ariaLabelrequired | string | — | Required: the tree is a structure of its own and needs a name. Shown in: A tree, Tick a selection, Load branches on demand and 5 more |
Also takes every attribute of <div> – without children.
TreeSearchProps<K, S extends Key = string>| Name | Type | Default | Description |
|---|---|---|---|
treerequired | Tree<K, S> | — | The same tree as the view's. The field does not hold the text itself –
it belongs to the tree, because the flattening depends on it. Shown in: Search with the path, With an error, Search, check and reveal in a large tree |
size | "sm" | "md" | the size of a ControlSizeProvider around it, else "md" | sm for a toolbar and dense forms, md otherwise. from InputPropsShown in: Search with the path, With an error, Search, check and reveal in a large tree and 3 more |
chars | number | — | The width in characters - room for the value; the field adds its own
padding and cross. Given, the field is that wide wherever it stands, and
never wider than its place. Without it the field fills its place, and is
20 characters wide where the place asks - in a toolbar or a row. from InputPropsShown in: Sized to the value (Sizes), Narrow places (Sizes), Configurator (Input) |
invalid | boolean | — | Marks the field as invalid. FormField sets it itself as soon as it
carries an error - by hand only necessary without FormField. from InputPropsShown in: With an error, Configurator (Input), With an error (Input) |
numeric | boolean | false | Right-aligned tabular figures in Geist Mono, e.g. for key figures. from InputPropsShown in: Configurator (Input), Align reference numbers (Input), Take a delivery address (Input) and 2 more |
clearable | boolean | false | Shows a gently appearing cross where there is content, which clears the
input. Requires a controlled field (value) and onClear. from InputPropsShown in: A row that holds still (Sizes), Clear the field (Input) |
ref | Ref<HTMLInputElement> | — | Receives the component's root element. from RefPropShown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more |
| Name | Type | Default | Description |
|---|---|---|---|
onClear | () => void | — | Called by the cross. Clearing belongs to the caller: only it knows
whether "empty" here is the empty text or no value at all. from InputPropsShown in: A row that holds still (Sizes), Clear the field (Input) |
Also takes every attribute of <input> – without size.
Tree<K, S extends Key = string>What useTree returns: the visible nodes and the gestures on them. Hand it
to TreeView and TreeSearch.
| Name | Type | Default | Description |
|---|---|---|---|
flatteningrequired | readonly FlatteningEntry<K, S>[] | — | The visible nodes – complete, even where only a window of them is rendered. Positions and sibling counts come from here and never from what stands in the document (ADR-0004). |
activerequired | S | null | — | Shown in: Search, check and reveal in a large tree |
searchrequired | string | — | |
toggleExpandedrequired | (key: S) => void | — | |
toggleCheckrequired | (key: S) => void | — | |
activaterequired | (key: S) => void | — | |
moverequired | (direction: Direction) => void | — | |
setSearchrequired | (text: string) => void | — | |
revealNoderequired | (key: S) => S | null | — | Expands the ancestors, brings the row into view and sets the active
node. Returns the key, or null if it does not exist in the data. Shown in: Search, check and reveal in a large tree |
revealPathrequired | (path: readonly S[]) => S | null | — | Like revealNode, but with a path the caller knows and the data does not yet - from a deep link, say. Returns the deepest key reached; if the run gets stuck at an unloaded branch, that is its key, and it has been expanded in the process. Loading and calling again is the caller's business. |
expandAllrequired | () => void | — | Shown in: Search, check and reveal in a large tree |
collapseAllrequired | () => void | — | Shown in: Search, check and reveal in a large tree |
typeaheadrequired | (prefix: string) => void | — | Moves the active node to the next one whose label starts like this. Changes nothing else – not the expanding, not the checks, not the search. |
checkTorequired | (to: S) => void | — | Checks the range from the anchor up to this node. |
moveAndCheckrequired | (direction: Direction) => void | — | Moves the active node and toggles the new one – one gesture, one report. Separate from move(), so that plain movement checks nothing; to be called instead of move(), not in addition to it. |
toggleAllrequired | () => void | — | Checks everything checkable – or empties, if everything is on already. |
revealCountrequired | number | — | Counts up every explicit request "show me this node". Otherwise the presentation follows the focus to the active node only where it already lies inside the tree - otherwise a state change from outside would tear the focus away. An explicit request, however, is exactly the opposite of an incidental change, and for that it needs a sign. |
virtual | VirtualRows | — | Set where virtualisation runs; goes to <TreeView virtual={…}>. |
FlatteningEntry<K, S extends Key = string>One visible node of a tree, with everything a row needs to be drawn and read out: level, branch or leaf, check state, search state and position among its siblings.
| Name | Type | Default | Description |
|---|---|---|---|
noderequired | K | — | Shown in: A tree, Tick a selection, Load branches on demand and 5 more |
keyrequired | S | — | |
levelrequired | number | — | Roots stand at 0. Drives the indentation and what is read out. |
labelrequired | string | — | The node's text; here, so that typeahead can be a pure function over the flattening and needs no reader. |
branchrequired | boolean | — | |
emptyrequired | boolean | — | Branch without content: expanding it showed nothing. An unloaded branch is not empty - it could have children. |
disabledrequired | boolean | — | May not be checked (ADR-0005). It stays navigable. |
unloadedrequired | boolean | — | Could have children that are not present. For checking exactly the same as disabled: whatever has to reach its descendants may not act on it. |
expandedrequired | boolean | — | |
checkedrequired | boolean | — | |
indeterminaterequired | boolean | — | Derived: not checked itself, but at least one descendant is. |
activerequired | boolean | — | |
matchesrequired | boolean | — | Matched the search itself. Without a search text every entry matches, so that the surface needs no special case. |
pathOnlyrequired | boolean | — | Stands there only because a descendant matches - a signpost. The surface dims these and only these. A child under a matching branch is neither a match nor a signpost but context: it stays normal. |
positionrequired | number | — | 1-based, among the siblings that survive the filter. |
siblingsrequired | number | — |
DirectionWhere the active node moves: up and down the visible rows, in and out of a branch, to the first or the last row.
type Direction = "up" | "down" | "in" | "out" | "start" | "end";VirtualRowsWhat useVirtual returns: the ref for the scroll area, the window of rows
to render and the heights of the filler rows around it.
| Name | Type | Default | Description |
|---|---|---|---|
scrollRefrequired | RefObject<HTMLDivElement | null> | — | Goes on the scroll area - in |
fromrequired | number | — | Index of the first rendered row within the filtered set. |
torequired | number | — | |
fillerBeforerequired | number | — | Height of the upper filler row, in pixels. |
fillerAfterrequired | number | — | Height of the lower filler row, in pixels. |
countrequired | number | — | Rows in total - the filler rows carry the rest. |
showRowrequired | (index: number) => void | — | Brings a row into view; for the keyboard. |
| Name | Type | Default | Description |
|---|---|---|---|
onScrollrequired | () => void | — | Reports a new scroll position to the container. Internal. |
What umriss deliberately does not build, and why: ADR-0032.