RowDetail

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.

Examples

Open a detail

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

Keep rows open while searching

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

Show the stops of a tour

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

Open a detail from outside the 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>
  );
}

Keyboard

Key Action
Enter On an expander: open or close the row's detail.
Space On an expander: open or close the row's detail.

API

RowDetailProps<Z>

NameTypeDefaultDescription
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

Every page of @umriss-ui/table

Getting started

Columns

Finding rows

Grouping

Rows

Around the table

Many rows

Editing

Limits and alarms

API index