Rows that hold rows: a hierarchy of any number of levels whose every level carries the same figures (also called tree data). Reach for it to drill down an organisation, regions and places or cost centres - levels the application holds, uneven as they are.
import { useTable } from "@umriss-ui/table";
childRows names the children of a row; the rows passed in are the roots. Every row with children gets a fold in the first column, and the next level stands beneath it, indented.
The figures of a row are the application's: the table sums nothing for a branch. The footer sums the top level only, since a parent already holds its children.
Every level sorts on its own, and a child never leaves its parent. A search or a filter shows a match with the rows above it, muted, and leaves the open branches as they were.
The export writes every row of the filtered tree, open or not, with its level first.
Name the children of a row with childRows: the table shows the roots, and every row with children gets a fold in the first column that opens the next level beneath it.
import { useTable } from "@umriss-ui/table";
interface Unit {
id: string;
name: string;
headcount: number;
children?: Unit[];
}
const UNITS: Unit[] = [
{
id: "sales",
name: "Sales",
headcount: 42,
children: [
{ id: "sales-north", name: "Sales North", headcount: 18 },
{ id: "sales-south", name: "Sales South", headcount: 24 },
],
},
{ id: "finance", name: "Finance", headcount: 11 },
{
id: "operations",
name: "Operations",
headcount: 96,
children: [
{ id: "site", name: "Site Ashcombe", headcount: 81 },
{ id: "logistics", name: "Logistics", headcount: 15 },
],
},
];
export default function ASmallTree() {
const { Table, Column } = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children });
return (
<Table ariaLabel="Organisation">
<Column value="name" label="Unit" rowHeader />
<Column value="headcount" label="Headcount" />
</Table>
);
}
Five levels in one division, one in the staff unit - each unit with figures of its own. The table opens the first level, sorts every level on its own, and the footer sums the top level only: a parent already holds its children. Search for “line” and the matches come with the rows above them, muted.
import { Search, Toolbar, ColumnMenu, useTable } from "@umriss-ui/table";
interface Unit {
id: string;
name: string;
budget: number;
spent: number;
children?: Unit[];
}
const UNITS: Unit[] = [
{
id: "ops",
name: "Operations",
budget: 4_800,
spent: 4_950,
children: [
{
id: "ops-ash",
name: "Site Ashcombe",
budget: 3_100,
spent: 3_320,
children: [
{
id: "ops-ash-a",
name: "Hall A",
budget: 1_700,
spent: 1_910,
children: [
{
id: "ops-ash-a-1",
name: "Line 1",
budget: 900,
spent: 1_080,
children: [
{ id: "ops-ash-a-1-press", name: "Press cell", budget: 520, spent: 690 },
{ id: "ops-ash-a-1-weld", name: "Welding cell", budget: 380, spent: 390 },
],
},
{ id: "ops-ash-a-2", name: "Line 2", budget: 800, spent: 830 },
],
},
{ id: "ops-ash-b", name: "Hall B", budget: 1_400, spent: 1_410 },
],
},
{ id: "ops-log", name: "Logistics", budget: 1_700, spent: 1_630 },
],
},
{
id: "sales",
name: "Sales",
budget: 2_200,
spent: 2_050,
children: [
{ id: "sales-n", name: "Region North", budget: 1_200, spent: 1_150 },
{ id: "sales-s", name: "Region South", budget: 1_000, spent: 900 },
],
},
{ id: "staff", name: "Staff unit", budget: 600, spent: 610 },
];
export default function AnUnevenOrganisation() {
const { Table, Column } = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children, defaultBranches: 1 });
return (
<Table ariaLabel="Budget by unit">
<Toolbar>
<Search />
<ColumnMenu />
</Toolbar>
<Column value="name" label="Unit" rowHeader searchable />
<Column value="budget" label="Budget (k€)" aggregate="sum" />
<Column value="spent" label="Spent (k€)" aggregate="sum" />
<Column value={(u) => u.spent - u.budget} id="delta" label="Δ (k€)" />
</Table>
);
}
One country has states, another goes straight to its cities: the tree stands as deep as each branch is. A column only places carry stays empty on a country - one column set for every level, absent where a level has no value.
import { useTable } from "@umriss-ui/table";
interface Place {
id: string;
name: string;
sites: number;
/** Only places have a postcode. */
postcode?: string;
children?: Place[];
}
const PLACES: Place[] = [
{
id: "de",
name: "Germany",
sites: 5,
children: [
{
id: "de-by",
name: "Bavaria",
sites: 3,
children: [
{ id: "de-by-muc", name: "Munich", sites: 2, postcode: "80331" },
{ id: "de-by-nue", name: "Nuremberg", sites: 1, postcode: "90402" },
],
},
{ id: "de-hh", name: "Hamburg", sites: 2, postcode: "20095" },
],
},
{
id: "nl",
name: "Netherlands",
sites: 2,
children: [
{ id: "nl-ams", name: "Amsterdam", sites: 1, postcode: "1012" },
{ id: "nl-rtm", name: "Rotterdam", sites: 1, postcode: "3011" },
],
},
{ id: "lu", name: "Luxembourg", sites: 1, postcode: "1009" },
];
export default function RegionsAndPlaces() {
const { Table, Column } = useTable(PLACES, { rowKey: (p) => p.id, childRows: (p) => p.children, defaultBranches: 2 });
return (
<Table ariaLabel="Sites by region">
<Column value="name" label="Region" rowHeader />
<Column value="sites" label="Sites" />
<Column value="postcode" label="Postcode" />
</Table>
);
}
When the bottom of the hierarchy is records of another kind, the tree carries the summarising columns and a team's RowDetail holds its employees - a table of their own, with every field they have and their own sort. In a tree the team's fold opens them: they hang from it a level deeper, on the table's surface, without a box.
import { useTable } from "@umriss-ui/table";
interface Employee {
id: string;
name: string;
role: string;
since: string;
hours: number;
}
interface Team {
id: string;
name: string;
headcount: number;
hours: number;
employees?: Employee[];
children?: Team[];
}
const TEAMS: Team[] = [
{
id: "it",
name: "IT",
headcount: 5,
hours: 196,
children: [
{
id: "it-ops",
name: "IT Operations",
headcount: 3,
hours: 118,
employees: [
{ id: "e1", name: "Ada Brenner", role: "Lead", since: "2016", hours: 40 },
{ id: "e2", name: "Jonas Kessler", role: "Administrator", since: "2021", hours: 40 },
{ id: "e3", name: "Mira Otto", role: "Administrator", since: "2024", hours: 38 },
],
},
{
id: "it-dev",
name: "Development",
headcount: 2,
hours: 78,
employees: [
{ id: "e4", name: "Lena Vogt", role: "Developer", since: "2019", hours: 40 },
{ id: "e5", name: "Tom Weiss", role: "Developer", since: "2023", hours: 38 },
],
},
],
},
];
function Employees({ team }: { team: Team }) {
const { Table, Column } = useTable(team.employees ?? [], { rowKey: (e) => e.id, defaultSort: { column: "name", direction: "asc" } });
return (
<Table ariaLabel={`Employees of ${team.name}`} density="compact">
<Column value="name" label="Employee" rowHeader />
<Column value="role" label="Role" />
<Column value="since" label="Since" />
<Column value="hours" label="Hours" />
</Table>
);
}
export default function EmployeesInTheDetail() {
const { Table, Column, RowDetail } = useTable(TEAMS, { rowKey: (t) => t.id, childRows: (t) => t.children, defaultBranches: 1 });
return (
<Table ariaLabel="Teams">
<Column value="name" label="Team" rowHeader />
<Column value="headcount" label="Headcount" />
<Column value="hours" label="Hours a week" />
<RowDetail>{(team) => (team.employees ? <Employees team={team} /> : null)}</RowDetail>
</Table>
);
}
A unit can have people of its own and teams beneath it. Its fold opens both: first its own staff on the rail, then its teams a level deeper - one chevron for everything that stands under the row. A unit without staff of its own opens only its teams; a team without teams only its staff.
import { useTable } from "@umriss-ui/table";
interface Employee {
id: string;
name: string;
role: string;
hours: number;
}
interface Unit {
id: string;
name: string;
headcount: number;
hours: number;
/** The people the unit has itself - not those of its teams. */
staff?: Employee[];
children?: Unit[];
}
const UNITS: Unit[] = [
{
id: "it",
name: "IT",
headcount: 6,
hours: 236,
staff: [{ id: "e0", name: "Nora Albers", role: "Head of IT", hours: 40 }],
children: [
{
id: "it-ops",
name: "IT Operations",
headcount: 3,
hours: 118,
staff: [
{ id: "e1", name: "Ada Brenner", role: "Lead", hours: 40 },
{ id: "e2", name: "Jonas Kessler", role: "Administrator", hours: 40 },
{ id: "e3", name: "Mira Otto", role: "Administrator", hours: 38 },
],
},
{
id: "it-dev",
name: "Development",
headcount: 2,
hours: 78,
staff: [
{ id: "e4", name: "Lena Vogt", role: "Developer", hours: 40 },
{ id: "e5", name: "Tom Weiss", role: "Developer", hours: 38 },
],
},
],
},
{
id: "fin",
name: "Finance",
headcount: 2,
hours: 72,
children: [
{
id: "fin-ctl",
name: "Controlling",
headcount: 2,
hours: 72,
staff: [
{ id: "e6", name: "Paula Hartmann", role: "Controller", hours: 40 },
{ id: "e7", name: "Ben Lindner", role: "Analyst", hours: 32 },
],
},
],
},
];
function Staff({ unit }: { unit: Unit }) {
const { Table, Column } = useTable(unit.staff ?? [], { rowKey: (e) => e.id });
return (
<Table ariaLabel={`Staff of ${unit.name}`} density="compact">
<Column value="name" label="Employee" rowHeader />
<Column value="role" label="Role" />
<Column value="hours" label="Hours" />
</Table>
);
}
export default function OwnStaffAndTeams() {
const { Table, Column, RowDetail } = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children, defaultBranches: ["it"] });
return (
<Table ariaLabel="Units with their staff">
<Column value="name" label="Unit" rowHeader />
<Column value="headcount" label="Headcount" />
<Column value="hours" label="Hours a week" />
<RowDetail>{(unit) => (unit.staff ? <Staff unit={unit} /> : null)}</RowDetail>
</Table>
);
}
Data from a database often comes flat, each record with its parent's id. A few lines nest it; the table takes the nested rows.
import { useMemo } from "react";
import { useTable } from "@umriss-ui/table";
interface CostCentre {
id: string;
parent: string | null;
name: string;
amount: number;
}
const FLAT: CostCentre[] = [
{ id: "1000", parent: null, name: "Administration", amount: 820 },
{ id: "1100", parent: "1000", name: "Accounting", amount: 310 },
{ id: "1110", parent: "1100", name: "Payroll", amount: 120 },
{ id: "1200", parent: "1000", name: "Legal", amount: 190 },
{ id: "2000", parent: null, name: "Production", amount: 4_300 },
{ id: "2100", parent: "2000", name: "Assembly", amount: 2_600 },
];
type Nested = CostCentre & { children?: Nested[] };
/** Nests a flat list by its parent ids - the roots are those without one. */
function nest(flat: readonly CostCentre[]): Nested[] {
const byId = new Map<string, Nested>(flat.map((c) => [c.id, { ...c }]));
const roots: Nested[] = [];
for (const c of byId.values()) {
const parent = c.parent === null ? undefined : byId.get(c.parent);
if (parent) (parent.children ??= []).push(c);
else roots.push(c);
}
return roots;
}
export default function FromAFlatList() {
const rows = useMemo(() => nest(FLAT), []);
const { Table, Column } = useTable(rows, { rowKey: (c) => c.id, childRows: (c) => c.children, defaultBranches: 1 });
return (
<Table ariaLabel="Cost centres">
<Column value="name" label="Name" rowHeader />
<Column value="id" label="Cost centre" />
<Column value="amount" label="Amount (k€)" />
</Table>
);
}
t.unfoldAllBranches and t.foldAllBranches open and close the whole tree; t.toggleBranch opens one branch by its key, and t.branches holds the keys open now - here to lead from a warning down to its unit.
import { Button, Stack, Text } from "@umriss-ui/core";
import { useTable } from "@umriss-ui/table";
interface Unit {
id: string;
name: string;
budget: number;
spent: number;
children?: Unit[];
}
const UNITS: Unit[] = [
{
id: "ops",
name: "Operations",
budget: 3_400,
spent: 3_560,
children: [
{
id: "ops-ash",
name: "Site Ashcombe",
budget: 2_600,
spent: 2_790,
children: [
{ id: "ops-ash-press", name: "Press shop", budget: 1_500, spent: 1_720 },
{ id: "ops-ash-paint", name: "Paint shop", budget: 1_100, spent: 1_070 },
],
},
{ id: "ops-log", name: "Logistics", budget: 800, spent: 770 },
],
},
{
id: "sales",
name: "Sales",
budget: 1_200,
spent: 1_150,
children: [
{ id: "sales-north", name: "Sales North", budget: 700, spent: 690 },
{ id: "sales-south", name: "Sales South", budget: 500, spent: 460 },
],
},
{ id: "finance", name: "Finance", budget: 400, spent: 390 },
];
/** The warning the controlling system raised, with the branches above its unit. */
const WARNING = { unit: "Press shop", path: ["ops", "ops-ash"] };
export default function OpenBranchesFromControls() {
const t = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children });
const { Table, Column } = t;
const closed = WARNING.path.filter((key) => !t.branches.includes(key));
return (
<Stack gap={3}>
<Stack direction="row" gap={2} align="center" wrap>
<Button size="sm" onClick={t.unfoldAllBranches}>
Open all
</Button>
<Button size="sm" onClick={t.foldAllBranches}>
Close all
</Button>
<Text size="sm" tone="secondary">
{WARNING.unit} is over budget.
</Text>
<Button size="sm" variant="ghost" disabled={closed.length === 0} onClick={() => closed.forEach((key) => t.toggleBranch(key))}>
Show it
</Button>
</Stack>
<Table ariaLabel="Budget by unit">
<Column value="name" label="Unit" rowHeader />
<Column value="budget" label="Budget (k€)" />
<Column value="spent" label="Spent (k€)" />
</Table>
</Stack>
);
}
TreeView from @umriss-ui/core| Key | Action |
|---|---|
→ / ← |
On a fold: opens or closes its branch; ← on a closed one goes to the parent's fold. |
Alt + → / Alt + ← |
On a fold: opens or closes the branch and all its siblings. |
From TableProps — the full table stands on First table.
| Name | Type | Default | Description |
|---|---|---|---|
density | "regular" | "compact" | the density of the UmrissProvider, else "regular" | How tightly the rows stand. |
children | ReactNode | — | Columns, RowDetail, RowActions and the unbound parts. |
| Name | Type | Default | Description |
|---|---|---|---|
ariaLabel | string | — | Accessible name of the table (not of the outer frame). |
From TableOptions — the full table stands on First table.
| Name | Type | Default | Description |
|---|---|---|---|
rowKeyrequired | (row: Z) => string | — | A stable key per row – the basis of selection and expansion. |
defaultSort | | SortLevel | null | The application's sort; it is the default that view leaves out. |
From TableSnapshot — the full table stands on First table.
| Name | Type | Default | Description |
|---|---|---|---|
branchesrequired | readonly string[] | — | Tree rows: the keys of the open branches. |
toggleBranchrequired | (key: string) => void | — | Tree rows: opens a branch or closes it. |
unfoldAllBranchesrequired | () => void | — | Tree rows: opens every branch that has children. |
foldAllBranchesrequired | () => void | — | Tree rows: closes every branch. |
From FieldColumn — the full table stands on Column.
| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | K | — | A field of the row. Supplies the column's id at the same time. |
From ColumnBase — the full table stands on Column.
| Name | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Names the column in its header, in the column menu and in the header row of the export. |
rowHeader | boolean | — | This column names the row: row header cells, never hideable, the column
stickyRowHeader pins, and the name by which the row's selection, expander and
actions are read out. At most one per table. |
searchable | boolean | true for text, false otherwise | Takes part in the search. |
From AggregateOptions — the full table stands on Aggregate.
| Name | Type | Default | Description |
|---|---|---|---|
aggregate | AggregateFor<W, Z> | — | What the column's values come to: in the footer over the filtered set,
in a group's header over its rows. "sum", "avg" for numbers; "min",
"max" for numbers and points in time; "range" for points in time;
"count", "distinct" for every value with a text form - or a function
of one's own. Always from the values, never from other aggregates; absent
values count towards nothing. |
What umriss deliberately does not build, and why: ADR-0032.