Cell Spanning
Merge cells downward or sideways. autoMerge joins runs of equal values on its own; rowSpan and colSpan take a callback and merge whatever you say. Spans are recomputed from the display order on every sort and filter, so they never drift.
Downward — autoMerge and rowSpan
Three merges, two mechanisms. Region and Country merge because their values repeat; Target merges across a whole region for the same reason; Sales rep merges in fixed blocks of four because a callback said so, which is the case autoMerge cannot express.
autoMerge: true is the whole configuration for the first three merged columns. It
follows the value: a run of adjacent rows resolving to the same thing becomes one
tall cell, and the run is as long as the data makes it.
- Country repeats four times — one merge per country.
- Region target is one number for the whole region, so the identical option produces a merge twelve rows tall. Nothing was configured differently; the data is.
Sales rep is where the two mechanisms come apart. Its merge is a
rowSpan callback returning a fixed 4:
rowSpan: ({ row }) => (row.quarter === 'Q1' ? 4 : 1)
Reach for rowSpan when the block is decided by something other than equality — a
fixed size, a flag on the row, a boundary in your own model. autoMerge has no notion
of "four"; it can only answer "the same as the one above".
The merge is visual only. Every row is still a row: sorting still moves all thirty-six, the merged column's data is untouched, and the spans are recomputed from the new display order. Sort by Quarter and watch the merges dissolve — the equal values are no longer adjacent, so there is nothing to join. That is the difference between a computed span and a merge baked into your markup.
Sideways — colSpan
A subtotal row at the end of each region: its label cell spans three columns to the right, so the row reads as one statement with a number at the end instead of a line of blanks.
colSpan merges rightward from the cell it is declared on. The
subtotal row at the end of each region uses it to take over the two columns after Country:
{ key: 'country', …,
colSpan: ({ row }) => (row.isTotal ? 3 : 1) }
Three, counting the cell itself — Country, Sales rep and Quarter. Amount is left alone, so the row reads as one label and one number instead of a label followed by two blanks.
A covered cell is not removed. It stays in the row, collapses to zero width and
renders nothing, while the spanning cell carries aria-colspan="3" — so the row keeps
the same number of cells it had before, and the layout is the only thing that changed.
The flag is yours, not the grid's. isTotal is a field on the row
model here; the grid never invents a subtotal row. Where those rows come from — your API, a
client-side reduce, or a grouping feature — is a separate decision, and
colSpan only asks what to do once one is on screen.
Three ways to merge, and when each fits
autoMerge: true— runs of adjacent rows with the same resolved value in that column become one cell. No callback, and the run length comes from the data.rowSpan: (ctx) => n— mergenrows downward, counting this one. Use it when the block is not defined by equality.colSpan: (ctx) => n— mergencolumns rightward, counting this one. Use it for a row that is a statement rather than a record.
Every callback receives { row, value, column, rowIndex } with
value already resolved, so a dotted prop path and a
valueGetter both work without you re-deriving anything.
Spans are computed, never stored
They are recomputed from the current display order on every sort, filter and page change. Two consequences worth knowing before you rely on them:
- A merge dissolves when its rows stop being adjacent — sort the first example by Quarter and the country merges are simply gone, because no two equal values are next to each other any more. That is correct, and it is why the order the rows arrive in matters.
- A merge never crosses a boundary: a page, a group header, a tree level or a full-width row all clamp it. A run that would have spilled past one stops at it instead.
Nothing about a span touches your data. Editing a merged cell edits its anchor
row — the top one for a rowSpan, the left one for a colSpan — and the
rows underneath keep whatever they held.
Sorting a merged column
The sort runs on the rows, not on the merged blocks, exactly as it would with no spanning at all. A column you intend to keep merged usually wants to be the one the grid is ordered by: that is what keeps its equal values adjacent, which is what keeps the merge.
rowSpan is not supported with scrollMode: 'virtual-paging' — a
row-span can reach into a window that has not been fetched, so the grid warns at config time
and leaves the column unmerged. autoMerge and colSpan are unaffected.
Cell spanning API
| Property | Type | Default | Description |
|---|---|---|---|
| column.autoMerge | boolean | false | Merge runs of adjacent rows whose resolved value in this column is the same. The run length comes from the data, not from you. |
| column.rowSpan | (ctx: SpanContext<T>) => number | — | Merge this cell downward over the returned number of rows, counting itself. Not supported with scrollMode: 'virtual-paging'. |
| column.colSpan | (ctx: SpanContext<T>) => number | — | Merge this cell rightward over the returned number of columns, counting itself. |
| SpanContext<T> | { row, value, column, rowIndex } | — | What both callbacks receive. value is already resolved through prop or valueGetter. |
| config.cellSpanning | boolean | auto | The kill switch. Spanning turns itself on from the column config; set false to force it off without editing the columns. |
| column.cellClass | string | ((row, value) => string) | — | Used on the second example to mark the subtotal rows. See Conditional Formatting. |