TreeView

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.

Examples

A tree

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

Tick a selection

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

Load branches on demand

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

Search with the path

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

With an error

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

Search, check and reveal in a large tree

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

When to use something else

Keyboard

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.

API

TreeViewProps<K, S extends Key = string>

NameTypeDefaultDescription
treerequiredTree<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

checkablebooleanfalseShow 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

refRef<HTMLDivElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Accessibility

NameTypeDefaultDescription
ariaLabelrequiredstring—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>

NameTypeDefaultDescription
treerequiredTree<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 InputProps

Shown in: Search with the path, With an error, Search, check and reveal in a large tree and 3 more

charsnumber—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 InputProps

Shown in: Sized to the value (Sizes), Narrow places (Sizes), Configurator (Input)

invalidboolean—Marks the field as invalid. FormField sets it itself as soon as it carries an error - by hand only necessary without FormField. from InputProps

Shown in: With an error, Configurator (Input), With an error (Input)

numericbooleanfalseRight-aligned tabular figures in Geist Mono, e.g. for key figures. from InputProps

Shown in: Configurator (Input), Align reference numbers (Input), Take a delivery address (Input) and 2 more

clearablebooleanfalseShows a gently appearing cross where there is content, which clears the input. Requires a controlled field (value) and onClear. from InputProps

Shown in: A row that holds still (Sizes), Clear the field (Input)

refRef<HTMLInputElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Events

NameTypeDefaultDescription
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 InputProps

Shown in: A row that holds still (Sizes), Clear the field (Input)

Also takes every attribute of <input> – without size.

Types on this page

Tree<K, S extends Key = string>

What useTree returns: the visible nodes and the gestures on them. Hand it to TreeView and TreeSearch.

NameTypeDefaultDescription
flatteningrequiredreadonly 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).
activerequiredS | null—

Shown in: Search, check and reveal in a large tree

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.
revealCountrequirednumber—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.
virtualVirtualRows—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.

NameTypeDefaultDescription
noderequiredK—

Shown in: A tree, Tick a selection, Load branches on demand and 5 more

keyrequiredS—
levelrequirednumber—Roots stand at 0. Drives the indentation and what is read out.
labelrequiredstring—The node's text; here, so that typeahead can be a pure function over the flattening and needs no reader.
branchrequiredboolean—
emptyrequiredboolean—Branch without content: expanding it showed nothing. An unloaded branch is not empty - it could have children.
disabledrequiredboolean—May not be checked (ADR-0005). It stays navigable.
unloadedrequiredboolean—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.
expandedrequiredboolean—
checkedrequiredboolean—
indeterminaterequiredboolean—Derived: not checked itself, but at least one descendant is.
activerequiredboolean—
matchesrequiredboolean—Matched the search itself. Without a search text every entry matches, so that the surface needs no special case.
pathOnlyrequiredboolean—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.
positionrequirednumber—1-based, among the siblings that survive the filter.
siblingsrequirednumber—

Direction

Where 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";

VirtualRows

What useVirtual returns: the ref for the scroll area, the window of rows to render and the heights of the filler rows around it.

NameTypeDefaultDescription
scrollRefrequiredRefObject<HTMLDivElement | null>—Goes on the scroll area - in
fromrequirednumber—Index of the first rendered row within the filtered set.
torequirednumber—
fillerBeforerequirednumber—Height of the upper filler row, in pixels.
fillerAfterrequirednumber—Height of the lower filler row, in pixels.
countrequirednumber—Rows in total - the filler rows carry the rest.
showRowrequired(index: number) => void—Brings a row into view; for the keyboard.

Events

NameTypeDefaultDescription
onScrollrequired() => void—Reports a new scroll position to the container. Internal.

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index