Home/Cells & Formatting/Cell Spanning
Cells & Formatting

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.

Region
Country
Region target
Sales rep
Quarter
Amount
EMEA
Germany
$4,200,000
Klaus M.
Q1
$180,000
Q2
$241,000
Q3
$302,000
Q4
$363,000
France
Amélie R.
Q1
$317,000
Q2
$378,000
Q3
$219,000
Q4
$280,000
Spain
Sofía G.
Q1
$234,000
Q2
$295,000
Q3
$356,000
Q4
$197,000
1–12 of 36

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.

Region
Country
Sales rep
Quarter
Amount
EMEA
Germany
Klaus M.
Q1
$180,000
Germany
Klaus M.
Q2
$241,000
Germany
Klaus M.
Q3
$302,000
Germany
Klaus M.
Q4
$363,000
France
Amélie R.
Q1
$317,000
France
Amélie R.
Q2
$378,000
France
Amélie R.
Q3
$219,000
France
Amélie R.
Q4
$280,000
Spain
Sofía G.
Q1
$234,000
Spain
Sofía G.
Q2
$295,000
Spain
Sofía G.
Q3
$356,000
Spain
Sofía G.
Q4
$197,000
EMEA total — 3 countries, 4 quarters
$3,362,000
1–13 of 39

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 — merge n rows downward, counting this one. Use it when the block is not defined by equality.
  • colSpan: (ctx) => n — merge n columns 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

PropertyTypeDefaultDescription
column.autoMergebooleanfalseMerge 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.cellSpanningbooleanautoThe kill switch. Spanning turns itself on from the column config; set false to force it off without editing the columns.
column.cellClassstring | ((row, value) => string)—Used on the second example to mark the subtotal rows. See Conditional Formatting.