React table row and column spanning
▶ Try it live: open a Mantine starter in StackBlitz — pass getCellSpan or column.colSpan. Other UI kits →
▶ See it working: merge cells in Mantine — Team is written once down the people who share it. Person stays its own cell. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.
A span is a rectangle. The origin cell carries colSpan / rowSpan; every
covered neighbour is omitted from that row’s cell list, so every kit maps one
list instead of columns.map. Omit getCellSpan and every column.colSpan /
column.rowSpan and the list is one cell per column — nothing extra renders.
By default the origin is painted like a spreadsheet merge: centered
content, one fill across the whole span (data-cell-span is
"colSpan x rowSpan", e.g. "1x5"). That is cellSpanAppearance="merged".
Pass "plain" for geometry only — same chrome as a 1×1 cell — if you want
to draw a calendar-style bar yourself. Override the fill with
--adapttable-cell-span-fill, or the unstyled cellSpan class hook.
import { DataTable } from "@adapttable/mantine";
<DataTable data={rows} columns={columns} rowKey={(row) => row.id} getCellSpan={({ column, row, sectionRows, sectionRowIndex }) => { if (column.key !== "team") return undefined; if (sectionRows[sectionRowIndex - 1]?.team === row.team) return undefined; let rowSpan = 1; while (sectionRows[sectionRowIndex + rowSpan]?.team === row.team) { rowSpan += 1; } return rowSpan > 1 ? { rowSpan } : undefined; }}/>;A column that always spans can say so on the definition:
{ key: "team", header: "Team", accessor: (row) => row.team, rowSpan: 2 }getCellSpan wins when both are set. Spans clamp to the remaining grid.
They clip at a column pin boundary (a pinned cell and a scrolling cell
cannot share one <td>) and at the column window: a span that starts
off-screen continues on the first visible column it covers.
Arrow keys skip a covered cell. CSV / XLSX write the origin value once and leave covered cells empty.
Mobile and the URL
Section titled “Mobile and the URL”Cards are a list of fields, not a grid — they ignore geometry and still show every column. Spans are derived from data, so there is nothing to put in the URL or a saved view.
Row spans stay inside one tbody. Consecutive teammates in visual order
(pinned top, then scroll, then pinned bottom) stay one merge — pinning a
Core person to the floor does not split Core into two cells. Kits render
those rows in the same tbody so HTML can express the span; a sticky pin
and a rowSpan taller than one row paint on top of each other, so the
sticky offset is skipped while any body cell spans more than one row.
The row still moves to the top or floor.