More about a row, opened beneath it on demand (also called an expandable or master-detail row). Reach for it when a row needs facts, notes or a small table of its own that would crowd the columns.
import { useTable } from "@umriss-ui/table";
The table puts an expander at the start of each row, named after the row – “Expand FP-1004210”. Several rows can be open at once, and a row stays open by its key: a search or a filter that hides it for a moment does not close it.
The detail spans all visible columns. Its function is called while the table renders, so a detail that needs a hook – a table of its own – goes into a component.
Put a RowDetail in the table and each row gets an expander; its function receives the row and returns what stands beneath it.
import { Grid, Stack, Text } from "@umriss-ui/core";
import { useTable } from "@umriss-ui/table";
interface Shipment {
id: string;
customer: string;
address: string;
window: string;
weight: number;
}
const SHIPMENTS: Shipment[] = [
{ id: "FP-1004210", customer: "Holloway Garden Supplies", address: "12 Orchard Lane, Ashcombe", window: "08:00–10:00", weight: 23 },
{ id: "FP-1004223", customer: "Brixley Cycles", address: "4 Station Road, Brixley", window: "08:00–10:00", weight: 14 },
{ id: "FP-1004236", customer: "Pellham Hardware", address: "88 High Street, Pellham", window: "10:00–12:00", weight: 4 },
];
function Fact({ name, value }: { name: string; value: string }) {
return (
<Stack gap={1}>
<Text size="xs" tone="muted">
{name}
</Text>
<Text size="sm">{value}</Text>
</Stack>
);
}
export default function OpenADetail() {
const { Table, Column, RowDetail } = useTable(SHIPMENTS, { rowKey: (s) => s.id });
return (
<Table ariaLabel="Shipments">
<Column value="id" label="Shipment" rowHeader />
<Column value="customer" label="Customer" />
<RowDetail>
{(s) => (
<Grid minItemWidth="160px" gap={4}>
<Fact name="Address" value={s.address} />
<Fact name="Delivery window" value={s.window} />
<Fact name="Weight" value={`${s.weight} kg`} />
</Grid>
)}
</RowDetail>
</Table>
);
}
Open two rows, search them away and back: a row stays open by its key, so the details return as they were.
import { Text } from "@umriss-ui/core";
import { Search, Toolbar, useTable } from "@umriss-ui/table";
interface Shipment {
id: string;
customer: string;
note: string;
}
const SHIPMENTS: Shipment[] = [
{ id: "FP-1004210", customer: "Holloway Garden Supplies", note: "Leave at the side gate if closed." },
{ id: "FP-1004223", customer: "Brixley Cycles", note: "Failed attempt at 08:40 – the shop opens at 09:30." },
{ id: "FP-1004236", customer: "Pellham Hardware", note: "Two pallets, tail lift needed." },
{ id: "FP-1004249", customer: "Ashcombe Dental", note: "Reception on the first floor." },
];
export default function KeepRowsOpen() {
const { Table, Column, RowDetail } = useTable(SHIPMENTS, { rowKey: (s) => s.id });
return (
<Table ariaLabel="Shipments with notes">
<Toolbar>
<Search placeholder="Search customer" />
</Toolbar>
<Column value="id" label="Shipment" rowHeader />
<Column value="customer" label="Customer" />
<RowDetail>{(s) => <Text size="sm">{s.note}</Text>}</RowDetail>
</Table>
);
}
A detail can hold a table of its own – give it a component, since useTable is a hook and the detail a plain function.
import { 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;
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));
const time = (t: number) => new Date(t).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
function Stops({ tour }: { tour: Tour }) {
const { Table, Column } = useTable(tour.stops, { rowKey: (s) => s.id });
return (
<Table density="compact" ariaLabel={`Stops of ${tour.id}`}>
<Column value="shipment" label="Shipment" rowHeader />
<Column value="customer" label="Customer" />
<Column id="window" label="Window" value={(s) => `${time(s.window[0])}–${time(s.window[1])}`} />
<Column id="arrival" label="Arrival" value={(s) => time(s.arrival)} />
</Table>
);
}
export default function StopsOfATour() {
const { Table, Column, RowDetail } = useTable(TOURS, { rowKey: (t) => t.id });
return (
<Table ariaLabel="Tours">
<Column value="id" label="Tour" rowHeader />
<Column id="driver" label="Driver" value={(t) => DRIVERS.find((d) => d.id === t.driver)?.name ?? t.driver} />
<Column id="stops" label="Stops" value={(t) => t.stops.length} />
<Column value="km" label="Distance (km)" />
<RowDetail>{(t) => <Stops tour={t} />}</RowDetail>
</Table>
);
}
t.toggleRow opens a row's detail by its key, or closes it; t.expanded holds the keys open now, whoever opened them - the expander or a control of the application's own.
import { Button, Grid, Stack, Text } from "@umriss-ui/core";
import { useTable } from "@umriss-ui/table";
interface Shipment {
id: string;
customer: string;
address: string;
window: string;
attempt: string | null;
}
const SHIPMENTS: Shipment[] = [
{ id: "FP-1004210", customer: "Holloway Garden Supplies", address: "12 Orchard Lane, Ashcombe", window: "08:00–10:00", attempt: null },
{ id: "FP-1004223", customer: "Oakridge Pharmacy", address: "3 Market Square, Oakridge", window: "08:00–10:00", attempt: "Nobody in at 09:12, card left" },
{ id: "FP-1004236", customer: "Brixley Cycles", address: "4 Station Road, Brixley", window: "10:00–12:00", attempt: null },
{ id: "FP-1004262", customer: "Pellham Hardware", address: "88 High Street, Pellham", window: "10:00–12:00", attempt: "Shop closed at 10:40" },
];
const FAILED = SHIPMENTS.filter((s) => s.attempt !== null);
export default function OpenADetailFromOutside() {
const t = useTable(SHIPMENTS, { rowKey: (s) => s.id });
const { Table, Column, RowDetail } = t;
const closedFailures = FAILED.filter((s) => !t.expanded.includes(s.id));
return (
<Stack gap={3}>
<Stack direction="row" gap={2} align="center" wrap>
<Text size="sm" tone="secondary">
{FAILED.length} failed attempts
</Text>
<Button size="sm" disabled={closedFailures.length === 0} onClick={() => closedFailures.forEach((s) => t.toggleRow(s.id))}>
Open them
</Button>
<Button size="sm" variant="ghost" disabled={t.expanded.length === 0} onClick={() => t.expanded.forEach((key) => t.toggleRow(key))}>
Close all details
</Button>
</Stack>
<Table ariaLabel="Shipments">
<Column value="id" label="Shipment" rowHeader />
<Column value="customer" label="Customer" />
<Column value="window" label="Window" />
<RowDetail>
{(s) => (
<Grid minItemWidth="160px" gap={4}>
<Text size="sm">{s.address}</Text>
<Text size="sm" tone="secondary">
{s.attempt ?? "No attempt yet"}
</Text>
</Grid>
)}
</RowDetail>
</Table>
</Stack>
);
}
| Key | Action |
|---|---|
Enter |
On an expander: open or close the row's detail. |
Space |
On an expander: open or close the row's detail. |
RowDetailProps<Z>| Name | Type | Default | Description |
|---|---|---|---|
childrenrequired | (row: Z) => ReactNode | — | What stands underneath an expanded row. Shown in: Open a detail, Keep rows open while searching, Show the stops of a tour and 5 more |