Gather rows that share a value under one header with their count and totals (also called row grouping). Reach for it when the question is per customer, per team or per month rather than per record.
import { useTable } from "@umriss-ui/table";
The outer levels become header rows that carry every total under its column; the innermost of several levels stands as a span beside its rows. The form follows the level, never the data (ADR-0029).
Grouping comes after search and filters: a group holds what they leave, and its totals are taken from its rows, never from the groups inside it.
Grouping is on for every table: the user groups from the column menu, up to three levels, and the table toolbar names the grouping.
defaultGrouping names a column; each tour gets a header row with its count and the sum under the weight column.
import { useTable } from "@umriss-ui/table";
interface Shipment {
id: string;
tour: string;
customer: string;
weight: number;
}
const SHIPMENTS: Shipment[] = [
{ id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", weight: 12.4 },
{ id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", weight: 2.1 },
{ id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", weight: 18.9 },
{ id: "FP-1004470", tour: "T-02", customer: "Tamsin's Bakery", weight: 6.5 },
{ id: "FP-1004483", tour: "T-02", customer: "Northfold Office", weight: 9.8 },
{ id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", weight: 214.0 },
{ id: "FP-1004743", tour: "T-03", customer: "Greywick Studio", weight: 188.5 },
];
export default function OneLevel() {
const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id, defaultGrouping: "tour" });
return (
<Table ariaLabel="Shipments by tour">
<Column value="id" label="Shipment" rowHeader />
<Column value="tour" label="Tour" />
<Column value="customer" label="Customer" />
<Column value="weight" label="Weight (kg)" format={{ decimals: 1 }} aggregate="sum" />
</Table>
);
}
With a ColumnMenu people group without any code: under Grouping, the first choice is the outer level, up to three.
import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table";
interface Incident {
id: string;
service: string;
severity: "SEV1" | "SEV2" | "SEV3";
assignee: string;
minutes: number;
}
const INCIDENTS: Incident[] = [
{ id: "INC-1048", service: "Checkout", severity: "SEV1", assignee: "Jonas Keller", minutes: 48 },
{ id: "INC-1047", service: "Webhooks", severity: "SEV3", assignee: "Ines Duarte", minutes: 195 },
{ id: "INC-1046", service: "Image service", severity: "SEV2", assignee: "Tomasz Nowak", minutes: 155 },
{ id: "INC-1045", service: "Sign-in", severity: "SEV2", assignee: "Ada Mwangi", minutes: 45 },
{ id: "INC-1043", service: "Search", severity: "SEV1", assignee: "Leila Haddad", minutes: 85 },
{ id: "INC-1042", service: "Notifications", severity: "SEV3", assignee: "Sam Okafor", minutes: 255 },
{ id: "INC-1041", service: "Billing", severity: "SEV2", assignee: "Priya Raman", minutes: 150 },
];
export default function NothingDeclared() {
const { Table, Column } = useTable(INCIDENTS, { rowKey: (i) => i.id });
return (
<Table ariaLabel="Incidents">
<Toolbar>
<ColumnMenu />
</Toolbar>
<Column value="id" label="Incident" rowHeader />
<Column value="service" label="Service" />
<Column value="severity" label="Severity" />
<Column value="assignee" label="Assignee" />
<Column value="minutes" label="Minutes to resolve" aggregate="sum" />
</Table>
);
}
With two keys the cost centres get header rows with their totals, and the suppliers stand once, as a span beside their invoices.
import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table";
interface Invoice {
id: string;
costCentre: string;
supplier: string;
description: string;
amount: number;
discount: number | null;
due: Date;
}
const day = (month: number, d: number) => new Date(2026, month - 1, d);
const INVOICES: Invoice[] = [
{ id: "INV-26-0318", costCentre: "Facilities", supplier: "Brandlow Office Supply", description: "Desk lamps", amount: 1056.24, discount: 117.36, due: day(4, 15) },
{ id: "INV-26-0301", costCentre: "Facilities", supplier: "Brandlow Office Supply", description: "Printer paper", amount: 860, discount: 0, due: day(3, 31) },
{ id: "INV-26-0288", costCentre: "Facilities", supplier: "Brandlow Office Supply", description: "Chairs", amount: 4210, discount: 210.5, due: day(3, 20) },
{ id: "INV-26-0312", costCentre: "Facilities", supplier: "Hartwell Cleaning", description: "Cleaning, March", amount: 2400, discount: null, due: day(4, 10) },
{ id: "INV-26-0290", costCentre: "Facilities", supplier: "Pennock Energy", description: "Electricity, February", amount: 3180, discount: 0, due: day(3, 24) },
{ id: "INV-26-0317", costCentre: "Marketing", supplier: "Kettering & Shaw Events", description: "Trade fair stand", amount: 11_780, discount: 620, due: day(4, 12) },
{ id: "INV-26-0305", costCentre: "Marketing", supplier: "Kettering & Shaw Events", description: "Catering", amount: 3330, discount: 0, due: day(4, 4) },
{ id: "INV-26-0296", costCentre: "Marketing", supplier: "Lumen Print", description: "Brochures", amount: 1940, discount: 97, due: day(3, 28) },
{ id: "INV-26-0284", costCentre: "Marketing", supplier: "Orla Ads", description: "Search ads, February", amount: 5600, discount: null, due: day(3, 18) },
{ id: "INV-26-0309", costCentre: "IT", supplier: "Nimbrel Software", description: "Design tool licences", amount: 5508, discount: 972, due: day(4, 8) },
{ id: "INV-26-0299", costCentre: "IT", supplier: "Nimbrel Software", description: "Onboarding session", amount: 1780, discount: 0, due: day(3, 29) },
{ id: "INV-26-0293", costCentre: "IT", supplier: "Castrel Hosting", description: "Servers, February", amount: 2890, discount: 0, due: day(3, 25) },
{ id: "INV-26-0311", costCentre: "Finance", supplier: "Fenwright Legal", description: "Contract review", amount: 3770, discount: 0, due: day(4, 9) },
];
export default function TwoLevels() {
const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: ["costCentre", "supplier"] });
return (
<Table ariaLabel="Invoices by cost centre and supplier">
<Toolbar>
<ColumnMenu />
</Toolbar>
<Column value="supplier" label="Supplier" />
<Column value="id" label="Invoice" rowHeader />
<Column value="costCentre" label="Cost centre" />
<Column value="description" label="Description" />
<Column value="amount" label="Amount (€)" format={{ decimals: 2 }} aggregate="sum" />
<Column value="discount" label="Discount (€)" format={{ decimals: 2 }} aggregate="sum" />
<Column value="due" label="Due" format="date" aggregate="range" />
</Table>
);
}
Tier and team become header rows, the team one step in; the service is the span beside its incidents. Three is the most there is.
import { useTable } from "@umriss-ui/table";
interface Incident {
id: string;
tier: string;
team: string;
service: string;
title: string;
minutes: number;
}
const INCIDENTS: Incident[] = [
{ id: "INC-1048", tier: "Tier 1", team: "Payments", service: "Checkout", title: "Card payments time out", minutes: 48 },
{ id: "INC-1039", tier: "Tier 1", team: "Payments", service: "Checkout", title: "Basket totals off by a cent", minutes: 95 },
{ id: "INC-1041", tier: "Tier 1", team: "Payments", service: "Billing", title: "Invoices generated twice", minutes: 150 },
{ id: "INC-1045", tier: "Tier 1", team: "Identity", service: "Sign-in", title: "Sign-in codes arrive late", minutes: 45 },
{ id: "INC-1043", tier: "Tier 1", team: "Discovery", service: "Search", title: "No results for some regions", minutes: 85 },
{ id: "INC-1046", tier: "Tier 2", team: "Discovery", service: "Image service", title: "Thumbnails missing", minutes: 155 },
{ id: "INC-1042", tier: "Tier 2", team: "Messaging", service: "Notifications", title: "Duplicate reminder e-mails", minutes: 255 },
{ id: "INC-1047", tier: "Tier 2", team: "Integrations", service: "Webhooks", title: "Deliveries delayed", minutes: 195 },
{ id: "INC-1044", tier: "Tier 3", team: "Insights", service: "Reporting", title: "Monthly export fails", minutes: 90 },
];
export default function ThreeLevels() {
const { Table, Column } = useTable(INCIDENTS, { rowKey: (i) => i.id, defaultGrouping: ["tier", "team", "service"] });
return (
<Table ariaLabel="Incidents by tier, team and service">
<Column value="service" label="Service" />
<Column value="id" label="Incident" rowHeader />
<Column value="tier" label="Tier" />
<Column value="team" label="Team" />
<Column value="title" label="Title" />
<Column value="minutes" label="Minutes to resolve" aggregate="sum" />
</Table>
);
}
group="month" brings each date to the start of its month; "day", "week" and "year" work the same, and the cells keep the full date.
import { useTable } from "@umriss-ui/table";
interface Invoice {
id: string;
supplier: string;
received: Date;
amount: number;
}
const INVOICES: Invoice[] = [
{ id: "INV-26-0102", supplier: "Pennock Energy", received: new Date(2026, 0, 9), amount: 3420 },
{ id: "INV-26-0131", supplier: "Castrel Hosting", received: new Date(2026, 0, 28), amount: 2890 },
{ id: "INV-26-0204", supplier: "Brandlow Office Supply", received: new Date(2026, 1, 4), amount: 860 },
{ id: "INV-26-0221", supplier: "Stellbrook Consulting", received: new Date(2026, 1, 21), amount: 8700 },
{ id: "INV-26-0226", supplier: "Corrin Travel", received: new Date(2026, 1, 26), amount: 8117.2 },
{ id: "INV-26-0309", supplier: "Nimbrel Software", received: new Date(2026, 2, 9), amount: 7288 },
{ id: "INV-26-0318", supplier: "Brandlow Office Supply", received: new Date(2026, 2, 16), amount: 1951.24 },
];
export default function ByMonth() {
const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: "received" });
return (
<Table ariaLabel="Invoices by month received">
<Column value="received" label="Received" format="date" group="month" />
<Column value="id" label="Invoice" rowHeader />
<Column value="supplier" label="Supplier" />
<Column value="amount" label="Amount (€)" format={{ decimals: 2 }} aggregate="sum" />
</Table>
);
}
groupValue says what to group by instead of the value, here an amount band; bands stand small before large, and cells keep the amount.
import { useTable } from "@umriss-ui/table";
interface Invoice {
id: string;
supplier: string;
amount: number;
}
const INVOICES: Invoice[] = [
{ id: "INV-26-0318", supplier: "Brandlow Office Supply", amount: 1951.24 },
{ id: "INV-26-0317", supplier: "Kettering & Shaw Events", amount: 15_110 },
{ id: "INV-26-0309", supplier: "Nimbrel Software", amount: 7288 },
{ id: "INV-26-0302", supplier: "Fenwright Legal", amount: 3770 },
{ id: "INV-26-0296", supplier: "Lumen Print", amount: 640 },
{ id: "INV-26-0290", supplier: "Pennock Energy", amount: 3180 },
{ id: "INV-26-0284", supplier: "Orla Ads", amount: 480 },
];
const band = (amount: number) => (amount < 1000 ? "Small (under €1,000)" : amount < 10_000 ? "Medium (€1,000–9,999)" : "Large (€10,000 and more)");
export default function InBands() {
const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: "size" });
return (
<Table ariaLabel="Invoices by size">
<Column id="size" value={(i) => i.amount} label="Size" groupValue={band} format={{ decimals: 2 }} />
<Column value="id" label="Invoice" rowHeader />
<Column value="supplier" label="Supplier" />
<Column value="amount" label="Amount (€)" format={{ decimals: 2 }} aggregate="sum" />
</Table>
);
}
GroupBy declares a group key computed from the row, here the day an incident opened; it has no cell and no export.
import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table";
interface Incident {
id: string;
service: string;
opened: Date;
minutes: number;
}
const at = (day: number, hours: number, minutes: number) => new Date(2026, 2, day, hours, minutes);
const INCIDENTS: Incident[] = [
{ id: "INC-1048", service: "Checkout", opened: at(17, 9, 42), minutes: 48 },
{ id: "INC-1047", service: "Webhooks", opened: at(17, 7, 15), minutes: 195 },
{ id: "INC-1046", service: "Image service", opened: at(16, 22, 5), minutes: 155 },
{ id: "INC-1045", service: "Sign-in", opened: at(16, 14, 20), minutes: 45 },
{ id: "INC-1044", service: "Reporting", opened: at(15, 10, 0), minutes: 90 },
{ id: "INC-1043", service: "Search", opened: at(14, 18, 30), minutes: 85 },
{ id: "INC-1042", service: "Notifications", opened: at(13, 8, 45), minutes: 255 },
];
/* Day first, so that the groups sort by date. */
const dayOf = (d: Date) => `${d.getDate()} March, ${d.toLocaleDateString("en-GB", { weekday: "long" })}`;
export default function KeyThatIsNoColumn() {
const { Table, Column, GroupBy } = useTable(INCIDENTS, { rowKey: (i) => i.id, defaultGrouping: "day" });
return (
<Table ariaLabel="Incidents by day">
<Toolbar>
<ColumnMenu />
</Toolbar>
<GroupBy id="day" value={(i) => dayOf(i.opened)} label="Day" />
<Column value="id" label="Incident" rowHeader />
<Column value="service" label="Service" />
<Column value="opened" label="Opened" format="dateTime" />
<Column value="minutes" label="Minutes to resolve" aggregate="avg" />
</Table>
);
}
Sorting a column with an aggregate orders the groups by it, and the rows within; defaultSort starts with the heaviest tour on top.
import { useTable } from "@umriss-ui/table";
interface Shipment {
id: string;
tour: string;
customer: string;
weight: number;
}
const SHIPMENTS: Shipment[] = [
{ id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", weight: 12.4 },
{ id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", weight: 2.1 },
{ id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", weight: 18.9 },
{ id: "FP-1004470", tour: "T-02", customer: "Tamsin's Bakery", weight: 6.5 },
{ id: "FP-1004483", tour: "T-02", customer: "Northfold Office", weight: 9.8 },
{ id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", weight: 214 },
{ id: "FP-1004743", tour: "T-03", customer: "Greywick Studio", weight: 188.5 },
];
export default function GroupsByAggregate() {
const { Table, Column } = useTable(SHIPMENTS, {
rowKey: (s) => s.id,
defaultGrouping: "tour",
defaultSort: { column: "weight", direction: "desc" },
});
return (
<Table ariaLabel="Shipments by tour, heaviest first">
<Column value="tour" label="Tour" />
<Column value="id" label="Shipment" rowHeader />
<Column value="customer" label="Customer" />
<Column value="weight" label="Weight (kg)" format={{ decimals: 1 }} aggregate="sum" />
</Table>
);
}
Folded, a grouped table is one line per group with its totals; folds are part of the view and come back through initialView.
import { Button, Stack, Text } from "@umriss-ui/core";
import { useTable } from "@umriss-ui/table";
/* Data from the controlling world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
interface CostCentre {
id: string;
name: string;
owner: string;
/** The budget of one month. */
monthlyBudget: number;
}
const COST_CENTRES: readonly CostCentre[] = [
{ id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 },
{ id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 },
{ id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 },
{ id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 },
{ id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 },
{ id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 },
{ id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 },
{ id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 },
{ id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 },
];
/** The year's months, as `"2026-01"` … `"2026-12"`. */
const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`);
/** The months whose books are closed: they have an actual. */
const CLOSED_MONTHS = 2;
interface LedgerRow {
costCentre: string;
month: string;
budget: number;
/** `null` while the month is open. */
actual: number | null;
forecast: number;
}
/* Where a cost centre lands against its budget, as a factor: Marketing's
spring campaign overspends, IT's delayed licences underspend. */
const TENDENCY: Readonly<Record<string, number>> = { "CC-1200": 1.14, "CC-4300": 0.88 };
/** Budget, actual and forecast of every cost centre in every month. */
const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => {
const r = random(310 + c);
const tendency = TENDENCY[centre.id] ?? 1;
return MONTHS.map((month, m) => {
/* December pays the bonuses, August is the quiet month. */
const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1;
const budget = Math.round(centre.monthlyBudget * season);
const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100;
const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null;
return { costCentre: centre.id, month, budget, actual, forecast };
});
});
const ROWS = LEDGER.map((row) => ({
key: `${row.costCentre} ${row.month}`,
centre: COST_CENTRES.find((c) => c.id === row.costCentre)!.name,
month: row.month,
budget: row.budget,
forecast: row.forecast,
actual: row.actual,
}));
/* A fold is kept by the path of its group. */
const path = (...values: string[]) => JSON.stringify(values.map((v) => `value:${v}`));
export default function Overview() {
const t = useTable(ROWS, {
rowKey: (r) => r.key,
defaultGrouping: "centre",
initialView: { folded: COST_CENTRES.filter((c) => c.name !== "Marketing").map((c) => path(c.name)) },
});
const { Table, Column } = t;
return (
<Stack gap={3}>
<Table ariaLabel="Budget by cost centre" maxHeight="480px" stickyHeader>
<Column value="centre" label="Cost centre" />
<Column value="month" label="Month" rowHeader />
<Column value="budget" label="Budget (€)" aggregate="sum" />
<Column value="forecast" label="Forecast (€)" aggregate="sum" />
<Column value="actual" label="Actual (€)" aggregate="sum" />
</Table>
<Stack direction="row" gap={2}>
<Button size="sm" onClick={t.foldAll}>
Fold all
</Button>
<Button size="sm" onClick={t.unfoldAll}>
Unfold all
</Button>
</Stack>
<Text as="code" size="xs" mono>
{JSON.stringify(t.view)}
</Text>
</Stack>
);
}
The checkbox in a group header selects all its rows, on other pages and in folded groups too; a bulk action receives the list.
import { Pagination, Toolbar, useTable } from "@umriss-ui/table";
interface Shipment {
id: string;
tour: string;
customer: string;
weight: number;
}
const SHIPMENTS: Shipment[] = [
{ id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", weight: 12.4 },
{ id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", weight: 2.1 },
{ id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", weight: 18.9 },
{ id: "FP-1004249", tour: "T-01", customer: "Tamsin's Bakery", weight: 6.5 },
{ id: "FP-1004483", tour: "T-02", customer: "Northfold Office", weight: 9.8 },
{ id: "FP-1004496", tour: "T-02", customer: "Ashcombe Dental", weight: 3.2 },
{ id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", weight: 214 },
{ id: "FP-1004743", tour: "T-03", customer: "Greywick Studio", weight: 188.5 },
];
export default function WholeGroup() {
const { Table, Column, RowActions, Action } = useTable(SHIPMENTS, {
rowKey: (s) => s.id,
defaultGrouping: "tour",
pageSize: 5,
});
return (
<Table ariaLabel="Shipments to reschedule" selectable>
<Toolbar />
<Pagination pageSizes={[5, 10]} />
<Column value="tour" label="Tour" />
<Column value="id" label="Shipment" rowHeader />
<Column value="customer" label="Customer" />
<Column value="weight" label="Weight (kg)" format={{ decimals: 1 }} aggregate="sum" />
<RowActions>
<Action bulk onSelect={(shipments) => window.alert(`Rescheduled: ${shipments.map((s) => s.id).join(", ")}`)}>
Reschedule
</Action>
</RowActions>
</Table>
);
}
A page counts header rows and rows alike; a page that begins inside a group repeats its header, marked as continued. Turn to page two.
import { Pagination, useTable } from "@umriss-ui/table";
interface Invoice {
id: string;
costCentre: string;
supplier: string;
amount: number;
}
const INVOICES: Invoice[] = [
{ id: "INV-26-0318", costCentre: "Facilities", supplier: "Brandlow Office Supply", amount: 1056.24 },
{ id: "INV-26-0301", costCentre: "Facilities", supplier: "Brandlow Office Supply", amount: 860 },
{ id: "INV-26-0312", costCentre: "Facilities", supplier: "Hartwell Cleaning", amount: 2400 },
{ id: "INV-26-0290", costCentre: "Facilities", supplier: "Pennock Energy", amount: 3180 },
{ id: "INV-26-0262", costCentre: "Facilities", supplier: "Pennock Energy", amount: 3420 },
{ id: "INV-26-0231", costCentre: "Facilities", supplier: "Pennock Energy", amount: 3610 },
{ id: "INV-26-0309", costCentre: "IT", supplier: "Castrel Hosting", amount: 2890 },
{ id: "INV-26-0299", costCentre: "IT", supplier: "Kestrel Security", amount: 1200 },
{ id: "INV-26-0293", costCentre: "IT", supplier: "Nimbrel Software", amount: 5508 },
{ id: "INV-26-0271", costCentre: "IT", supplier: "Nimbrel Software", amount: 1780 },
];
export default function Paging() {
const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: ["costCentre", "supplier"], pageSize: 5 });
return (
<Table ariaLabel="Invoices, five lines per page">
<Pagination pageSizes={[5, 10, 25]} />
<Column value="supplier" label="Supplier" />
<Column value="id" label="Invoice" rowHeader />
<Column value="costCentre" label="Cost centre" />
<Column value="amount" label="Amount (€)" format={{ decimals: 2 }} aggregate="sum" />
</Table>
);
}
Grouping comes after search and filters: type “pharmacy” and the tours without one disappear, the counts and sums shrink with them.
import { Search, Toolbar, useTable } from "@umriss-ui/table";
interface Shipment {
id: string;
tour: string;
customer: string;
status: string;
weight: number;
}
const SHIPMENTS: Shipment[] = [
{ id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", status: "delivered", weight: 12.4 },
{ id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", status: "delivered", weight: 2.1 },
{ id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", status: "out for delivery", weight: 18.9 },
{ id: "FP-1004470", tour: "T-02", customer: "Tamsin's Bakery", status: "failed attempt", weight: 6.5 },
{ id: "FP-1004483", tour: "T-02", customer: "Northfold Office", status: "out for delivery", weight: 9.8 },
{ id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", status: "delivered", weight: 214 },
{ id: "FP-1004756", tour: "T-03", customer: "Oakridge Pharmacy", status: "out for delivery", weight: 183.2 },
];
export default function FilterAndSearch() {
const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id, defaultGrouping: "tour" });
return (
<Table ariaLabel="Shipments by tour, searchable">
<Toolbar>
<Search />
</Toolbar>
<Column value="tour" label="Tour" />
<Column value="id" label="Shipment" rowHeader />
<Column value="customer" label="Customer" />
<Column value="status" label="Status" filter="list" />
<Column value="weight" label="Weight (kg)" format={{ decimals: 1 }} aggregate="sum" />
</Table>
);
}
groupable={false} on a column keeps it out of the grouping choice; on the Table it keeps a fixed report from being grouped at all.
import { Stack } from "@umriss-ui/core";
import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table";
interface Incident {
id: string;
service: string;
severity: string;
minutes: number;
}
const INCIDENTS: Incident[] = [
{ id: "INC-1048", service: "Checkout", severity: "SEV1", minutes: 48 },
{ id: "INC-1046", service: "Image service", severity: "SEV2", minutes: 155 },
{ id: "INC-1045", service: "Sign-in", severity: "SEV2", minutes: 45 },
{ id: "INC-1042", service: "Notifications", severity: "SEV3", minutes: 255 },
];
export default function SwitchedOff() {
const list = useTable(INCIDENTS, { rowKey: (i) => i.id });
const report = useTable(INCIDENTS, { rowKey: (i) => i.id });
return (
<Stack gap={4}>
<list.Table ariaLabel="Incidents, groupable but not by id">
<Toolbar>
<ColumnMenu />
</Toolbar>
<list.Column value="id" label="Incident" rowHeader groupable={false} />
<list.Column value="service" label="Service" />
<list.Column value="severity" label="Severity" />
<list.Column value="minutes" label="Minutes to resolve" groupable={false} aggregate="sum" />
</list.Table>
<report.Table ariaLabel="Incident report, never grouped" groupable={false}>
<Toolbar>
<ColumnMenu />
</Toolbar>
<report.Column value="id" label="Incident" rowHeader />
<report.Column value="service" label="Service" />
<report.Column value="severity" label="Severity" />
<report.Column value="minutes" label="Minutes to resolve" aggregate="sum" />
</report.Table>
</Stack>
);
}
Rows without a value form the group “No value”, which stands last in either sort direction. Click the Person header twice.
import { useTable } from "@umriss-ui/table";
interface WorkItem {
id: string;
person: string | null;
name: string;
estimate: number;
}
const WORK: WorkItem[] = [
{ id: "W-102", person: "Arjun Mehta", name: "Profile page", estimate: 26 },
{ id: "W-117", person: null, name: "Password reset e-mail", estimate: 8 },
{ id: "W-105", person: "Chloe Durand", name: "Change of address form", estimate: 24 },
{ id: "W-104", person: "Chloe Durand", name: "Basket keeps items across devices", estimate: 20 },
{ id: "W-118", person: null, name: "Cookie banner wording", estimate: 3 },
{ id: "W-109", person: "Eva Novak", name: "Regression run", estimate: 20 },
{ id: "W-119", person: null, name: "Accessibility audit", estimate: 16 },
];
export default function NoValue() {
const { Table, Column } = useTable(WORK, { rowKey: (w) => w.id, defaultGrouping: "person" });
return (
<Table ariaLabel="Work items by person">
<Column value="person" label="Person" />
<Column value="id" label="Item" rowHeader />
<Column value="name" label="Title" />
<Column value="estimate" label="Estimate (h)" aggregate="sum" />
</Table>
);
}
A group of one row keeps its fold, count and totals; as the innermost level it is a span, so it costs no extra line.
import { useTable } from "@umriss-ui/table";
interface WorkItem {
id: string;
team: string;
person: string;
name: string;
estimate: number;
}
const WORK: WorkItem[] = [
{ id: "W-101", team: "Web", person: "Arjun Mehta", name: "Sign-in with e-mail code", estimate: 18 },
{ id: "W-102", team: "Web", person: "Arjun Mehta", name: "Profile page", estimate: 26 },
{ id: "W-103", team: "Web", person: "Chloe Durand", name: "Session handling", estimate: 12 },
{ id: "W-106", team: "Web", person: "Noah Fischer", name: "Profile page design", estimate: 14 },
{ id: "W-108", team: "Web", person: "Eva Novak", name: "Test plan for sign-in", estimate: 10 },
{ id: "W-110", team: "Apps", person: "Hana Sato", name: "Reminder scheduling service", estimate: 32 },
{ id: "W-112", team: "Apps", person: "Kofi Mensah", name: "Calendar sync", estimate: 18 },
];
export default function GroupOfOne() {
const { Table, Column } = useTable(WORK, { rowKey: (w) => w.id, defaultGrouping: ["team", "person"] });
return (
<Table ariaLabel="Work items by team and person">
<Column value="person" label="Person" />
<Column value="id" label="Item" rowHeader />
<Column value="team" label="Team" />
<Column value="name" label="Title" />
<Column value="estimate" label="Estimate (h)" aggregate="sum" />
</Table>
);
}
A long value in a span is cut and kept in its title, as every value in the table stays on its line; rows without a project gather last.
import { useTable } from "@umriss-ui/table";
interface WorkItem {
id: string;
team: string;
project: string | null;
person: string;
name: string;
estimate: number;
}
const PORTAL = "Member portal for Rowan Credit Union – sign-in, profile, change of address and statements";
const WORK: WorkItem[] = [
{ id: "W-101", team: "Web", project: PORTAL, person: "Arjun Mehta", name: "Sign-in with e-mail code", estimate: 18 },
{ id: "W-102", team: "Web", project: PORTAL, person: "Arjun Mehta", name: "Profile page", estimate: 26 },
{ id: "W-106", team: "Web", project: PORTAL, person: "Noah Fischer", name: "Profile page design", estimate: 14 },
{ id: "W-104", team: "Web", project: "Online shop relaunch", person: "Chloe Durand", name: "Basket keeps items across devices", estimate: 20 },
{ id: "W-120", team: "Web", project: null, person: "Eva Novak", name: "Update the test devices", estimate: 4 },
{ id: "W-121", team: "Web", project: null, person: "Maya Lindgren", name: "Quarterly planning", estimate: 6 },
{ id: "W-110", team: "Apps", project: "Booking app", person: "Hana Sato", name: "Reminder scheduling service", estimate: 32 },
{ id: "W-112", team: "Apps", project: "Booking app", person: "Kofi Mensah", name: "Calendar sync", estimate: 18 },
{ id: "W-114", team: "Apps", project: "Booking app", person: "Kofi Mensah", name: "Reminder settings screen", estimate: 16 },
{ id: "W-113", team: "Apps", project: "Intranet", person: "Kofi Mensah", name: "News feed", estimate: 16 },
];
export default function CompactAndLong() {
const { Table, Column } = useTable(WORK, { rowKey: (w) => w.id, defaultGrouping: ["team", "project", "person"] });
return (
<Table ariaLabel="Backlog, compact" density="compact" selectable>
<Column value="person" label="Person" />
<Column value="id" label="Item" rowHeader />
<Column value="team" label="Team" />
<Column value="project" label="Project" />
<Column value="name" label="Title" />
<Column value="estimate" label="Estimate (h)" aggregate="sum" />
</Table>
);
}
Grouping works with virtual: the window counts header rows and rows alike, and headers stick one below the other while their group scrolls.
import { useMemo } from "react";
import { useTable } from "@umriss-ui/table";
interface Request {
id: string;
service: string;
at: Date;
duration: number;
size: number;
}
const SERVICES = ["Checkout", "Billing", "Sign-in", "Search", "Image service", "Notifications", "Webhooks", "Reporting"] as const;
/* mulberry32: the same seed yields the same requests on every run. */
function generate(count: number): Request[] {
let seed = 20260317;
const next = () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
/* Three days, one request every thirteen seconds, in the order they came. */
const start = new Date(2026, 2, 15, 6).getTime();
return Array.from({ length: count }, (_, i) => ({
id: `r${i}`,
service: SERVICES[Math.floor(next() * SERVICES.length)]!,
at: new Date(start + i * 13_000),
duration: Math.round(20 + next() * 900),
size: Math.round(next() * 5000) / 10,
}));
}
/* The part of the day a request came in - a key, not a column. */
const partOf = (d: Date) => (d.getHours() < 6 ? "Night" : d.getHours() < 12 ? "Morning" : d.getHours() < 18 ? "Afternoon" : "Evening");
export default function TwentyThousand() {
const requests = useMemo(() => generate(20_000), []);
const { Table, Column, GroupBy } = useTable(requests, {
rowKey: (r) => r.id,
defaultGrouping: ["service", "part"],
virtual: true,
});
return (
<Table ariaLabel="Requests" maxHeight="480px" stickyHeader>
<Column value="service" label="Service" />
<Column value="at" label="Received" format="dateTime" aggregate="range" />
<Column value="duration" label="Duration (ms)" format={{ decimals: 0 }} aggregate="avg" />
<Column value="size" label="Size (kB)" format={{ decimals: 1 }} aggregate="max" />
<GroupBy id="part" value={(r) => partOf(r.at)} label="Part of the day" />
</Table>
);
}
Everything at once: depot and part of the day, weights with their share, an on-time rate of its own, the worst delay, and a bulk action.
import type { LimitSet } from "@umriss-ui/core";
import { ColumnMenu, Export, Search, Toolbar, useTable } from "@umriss-ui/table";
/* Data from the logistics world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(10, 30);
const DEPOTS = [
{ id: "north", name: "North depot" },
{ id: "river", name: "Riverside depot" },
{ id: "east", name: "East Gate depot" },
] as const;
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
interface Shipment {
id: string;
customer: string;
/** In kg. */
weight: number;
status: "delivered" | "out for delivery" | "failed attempt";
window: readonly [number, number];
tour: string;
}
/** One shipment per stop. Before now, delivered - or a failed attempt, where
nobody was in; after now, out for delivery. */
const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
one.stops.map((stop) => {
const n = Number(stop.shipment.slice(3));
return {
id: stop.shipment,
customer: stop.customer,
weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
window: stop.window,
tour: one.id,
} satisfies Shipment;
}),
);
/* One row per stop: the shipment, its tour's depot, and how late the tour
plans to arrive after the promised window closes. */
const STOPS = TOURS.flatMap((tour) =>
tour.stops.map((stop) => ({
id: stop.shipment,
depot: DEPOTS.find((d) => d.id === tour.depot)!.name,
tour: tour.id,
window: new Date(stop.window[0]),
customer: stop.customer,
weight: SHIPMENTS.find((s) => s.id === stop.shipment)!.weight,
delay: Math.max(0, Math.round((stop.arrival - stop.window[1]) / MINUTE)),
})),
);
type StopRow = (typeof STOPS)[number];
const DELAY: LimitSet = {
limits: [
{ value: 1, side: "upper", severity: "warning" },
{ value: 45, side: "upper", severity: "alarm" },
],
};
const partOf = (d: Date) => (d.getHours() < 12 ? "Morning" : "Afternoon");
/* Stops on time over all stops of the group - a rate is the application's to
compute, never an average of rates. */
const onTime = (_values: readonly number[], stops: readonly StopRow[]) => stops.filter((s) => s.delay === 0).length / stops.length;
export default function DispatchReport() {
const { Table, Column, VerdictColumn, GroupBy, RowActions, Action } = useTable(STOPS, {
rowKey: (s) => s.id,
defaultGrouping: ["depot", "part"],
});
return (
<Table ariaLabel="Deliveries by depot" selectable stickyHeader maxHeight="560px">
<Toolbar>
<Search />
<ColumnMenu />
<Export filename="deliveries.csv" />
</Toolbar>
<GroupBy id="part" value={(s) => partOf(s.window)} label="Part of the day" />
<Column value="id" label="Shipment" rowHeader />
<Column value="depot" label="Depot" />
<Column value="tour" label="Tour" aggregate="distinct" />
<Column value="window" label="Window from" format="time" aggregate="range" />
<Column value="customer" label="Customer" aggregate="distinct" />
<Column value="weight" label="Weight (kg)" format={{ decimals: 1 }} aggregate="sum" />
<Column id="onTime" value={(s) => Number(s.delay === 0)} label="On time" format="percent" aggregate={onTime} />
<VerdictColumn value="delay" label="Delay (min)" limits={DELAY} aggregate="worst" />
<RowActions>
<Action bulk onSelect={(stops) => window.alert(`Rescheduled: ${stops.map((s) => s.id).join(", ")}`)}>
Reschedule
</Action>
</RowActions>
</Table>
);
}
t.setGrouping groups by what a control of the application chooses, [] ungroups; t.toggleFold folds one group by its path, and t.folded holds the paths folded now.
import { Button, Select, Stack } from "@umriss-ui/core";
import { useTable } from "@umriss-ui/table";
interface Shipment {
id: string;
tour: string;
customer: string;
status: "delivered" | "out for delivery";
weight: number;
}
const SHIPMENTS: Shipment[] = [
{ id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", status: "delivered", weight: 12.4 },
{ id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", status: "delivered", weight: 2.1 },
{ id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", status: "delivered", weight: 18.9 },
{ id: "FP-1004470", tour: "T-02", customer: "Tamsin's Bakery", status: "delivered", weight: 6.5 },
{ id: "FP-1004483", tour: "T-02", customer: "Northfold Office", status: "out for delivery", weight: 9.8 },
{ id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", status: "delivered", weight: 214.0 },
{ id: "FP-1004743", tour: "T-03", customer: "Greywick Studio", status: "delivered", weight: 188.5 },
];
/* A fold is kept by the path of its group. */
const path = (...values: string[]) => JSON.stringify(values.map((v) => `value:${v}`));
/** The tours with nothing left to deliver - what a dispatcher folds away. */
const FINISHED = [...new Set(SHIPMENTS.map((s) => s.tour))].filter((tour) =>
SHIPMENTS.every((s) => s.tour !== tour || s.status === "delivered"),
);
export default function GroupAndFoldFromControls() {
const t = useTable(SHIPMENTS, { rowKey: (s) => s.id, defaultGrouping: "tour" });
const { Table, Column } = t;
const byTour = t.grouping[0] === "tour";
const open = FINISHED.filter((tour) => !t.folded.includes(path(tour)));
return (
<Stack gap={3}>
<Stack direction="row" gap={2} align="center" wrap>
<Select
size="sm"
aria-label="Group by"
value={t.grouping[0] ?? ""}
onChange={(event) => t.setGrouping(event.target.value ? [event.target.value] : [])}
>
<option value="tour">By tour</option>
<option value="status">By status</option>
<option value="">Not grouped</option>
</Select>
<Button size="sm" disabled={!byTour || open.length === 0} onClick={() => open.forEach((tour) => t.toggleFold(path(tour)))}>
Fold finished tours
</Button>
</Stack>
<Table ariaLabel="Shipments by tour">
<Column value="id" label="Shipment" rowHeader />
<Column value="tour" label="Tour" />
<Column value="customer" label="Customer" />
<Column value="status" label="Status" />
<Column value="weight" label="Weight (kg)" format={{ decimals: 1 }} aggregate="sum" />
</Table>
</Stack>
);
}
| Key | Action |
|---|---|
→ / ← |
On a fold: unfolds or folds its group. |
Alt + → / Alt + ← |
On a fold: unfolds or folds the group and all its siblings. |
GroupByBase| Name | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Names the group key where a grouping is chosen, and in the grouping's tag in the table toolbar. Shown in: Group by a value that is no column, Group twenty thousand rows, Read a day's deliveries by depot |
What umriss deliberately does not build, and why: ADR-0032.