Merge cells
Merge adjacent cells, using the Ctrl+M shortcut or the context menu. Control merged cells, using Handsontable’s API.
Overview
By merging, you can combine two or more adjacent cells into a single cell that spans several rows or columns.
Handsontable merges cells in the same way as Microsoft Excel: keeps only the upper-left value of the selected range and clears other values. This clearing happens in the underlying data, not just on screen — see Effect on the underlying data.
How to merge cells
To enable the merge cells feature, set the mergeCells option to true or to an array.
To initialize Handsontable with predefined merged cells, provide merged cells details in form of an array:
The row and col properties use visual indexes. They refer to positions as displayed in the grid, in the same coordinate space as selectCell() and getCellMeta().
mergeCells={[ { row: 1, col: 1, rowspan: 3, colspan: 3 }, { row: 3, col: 4, rowspan: 2, colspan: 2 }, { row: 5, col: 6, rowspan: 3, colspan: 3 },]}import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
// generate an array of arrays with dummy dataconst data = new Array(100) // number of rows .fill(null) .map((_, row) => new Array(50) // number of columns .fill(null) .map((_, column) => `${row}, ${column}`) );
const ExampleComponent = () => { return ( <HotTable data={data} height={320} autoColumnSize={{ allowSampleDuplicates: true, samplingRatio: 100, }} rowHeaders={true} colHeaders={true} contextMenu={true} mergeCells={[ { row: 1, col: 1, rowspan: 3, colspan: 3 }, { row: 3, col: 4, rowspan: 2, colspan: 2 }, { row: 5, col: 6, rowspan: 3, colspan: 3 }, ]} autoWrapRow={true} autoWrapCol={true} licenseKey="non-commercial-and-evaluation" /> );};
export default ExampleComponent;import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
// generate an array of arrays with dummy dataconst data = new Array(100) // number of rows .fill(null) .map((_, row) => new Array(50) // number of columns .fill(null) .map((_, column) => `${row}, ${column}`) );
const ExampleComponent = () => { return ( <HotTable data={data} height={320} autoColumnSize={{ allowSampleDuplicates: true, samplingRatio: 100, }} rowHeaders={true} colHeaders={true} contextMenu={true} mergeCells={[ { row: 1, col: 1, rowspan: 3, colspan: 3 }, { row: 3, col: 4, rowspan: 2, colspan: 2 }, { row: 5, col: 6, rowspan: 3, colspan: 3 }, ]} autoWrapRow={true} autoWrapCol={true} licenseKey="non-commercial-and-evaluation" /> );};
export default ExampleComponent;Optimizing rendering of the wide/tall merged cells
When cells span thousands of rows or columns, scrolling may feel slower compared to unmerged cells. To improve performance, consider enabling the dedicated virtualization feature for merged cells, which is disabled by default.
To enable the merged cells virtualization mode, enable the virtualized option:
mergeCells={{ virtualized: true, cells: [{ row: 1, col: 1, rowspan: 200, colspan: 2 }]}}The example below uses virtualized merged cells. It’s also recommended to increase the buffer of rendered rows/columns to minimize the flickering effects.
import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
// generate an array of arrays with dummy dataconst data = new Array(50) // number of rows .fill(null) .map((_, row) => new Array(500) // number of columns .fill(null) .map((_, column) => `${row}, ${column}`) );
const ExampleComponent = () => { return ( <HotTable data={data} height={320} colWidths={100} rowHeaders={true} colHeaders={true} contextMenu={true} mergeCells={{ virtualized: true, cells: [{ row: 1, col: 1, rowspan: 3, colspan: 498 }], }} viewportColumnRenderingOffset={15} viewportColumnRenderingThreshold={5} autoWrapRow={true} autoWrapCol={true} licenseKey="non-commercial-and-evaluation" /> );};
export default ExampleComponent;import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
// generate an array of arrays with dummy dataconst data = new Array(50) // number of rows .fill(null) .map((_, row) => new Array(500) // number of columns .fill(null) .map((_, column) => `${row}, ${column}`) );
const ExampleComponent = () => { return ( <HotTable data={data} height={320} colWidths={100} rowHeaders={true} colHeaders={true} contextMenu={true} mergeCells={{ virtualized: true, cells: [{ row: 1, col: 1, rowspan: 3, colspan: 498 }], }} viewportColumnRenderingOffset={15} viewportColumnRenderingThreshold={5} autoWrapRow={true} autoWrapCol={true} licenseKey="non-commercial-and-evaluation" /> );};
export default ExampleComponent;React to merge and unmerge events
To run your own logic when cells merge or unmerge, use the beforeMergeCells, afterMergeCells, beforeUnmergeCells, and afterUnmergeCells hooks. Each hook receives the affected cellRange, and afterMergeCells also receives the resulting mergeParent object (row, col, rowspan, colspan).
The example below logs a message every time you merge or unmerge cells, using the context menu or the Ctrl+M shortcut.
import { useState } from 'react';import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
const ExampleComponent = () => { const [output, setOutput] = useState('');
const logEvent = (message) => { setOutput((previousOutput) => `${message}\n${previousOutput}`); };
return ( <> <output className="console" id="example3-output"> {output || 'Select cells, then press Ctrl+M (or use the context menu) to merge or unmerge them. Hook activity appears here.'} </output> <HotTable data={[ ['North America', 420000, 465000, 501000], ['Europe', 388000, 402000, 411000], ['APAC', 275000, 298000, 312000], ['Latin America', 142000, 151000, 158000], ['Middle East', 96000, 101000, 108000], ]} colHeaders={['Region', 'Jan 2025', 'Feb 2025', 'Mar 2025']} rowHeaders={true} height="auto" contextMenu={true} mergeCells={true} autoWrapRow={true} autoWrapCol={true} beforeMergeCells={(cellRange) => { logEvent(`beforeMergeCells: rows ${cellRange.from.row}-${cellRange.to.row}, columns ${cellRange.from.col}-${cellRange.to.col}.`); }} afterMergeCells={(cellRange, mergeParent) => { logEvent(`afterMergeCells: merged into ${mergeParent.rowspan} row(s) by ${mergeParent.colspan} column(s).`); }} beforeUnmergeCells={(cellRange) => { logEvent(`beforeUnmergeCells: rows ${cellRange.from.row}-${cellRange.to.row}, columns ${cellRange.from.col}-${cellRange.to.col}.`); }} afterUnmergeCells={(cellRange) => { logEvent(`afterUnmergeCells: rows ${cellRange.from.row}-${cellRange.to.row}, columns ${cellRange.from.col}-${cellRange.to.col}.`); }} licenseKey="non-commercial-and-evaluation" /> </> );};
export default ExampleComponent;import { useState } from 'react';import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
const ExampleComponent = () => { const [output, setOutput] = useState('');
const logEvent = (message: string) => { setOutput((previousOutput) => `${message}\n${previousOutput}`); };
return ( <> <output className="console" id="example3-output"> {output || 'Select cells, then press Ctrl+M (or use the context menu) to merge or unmerge them. Hook activity appears here.'} </output> <HotTable data={[ ['North America', 420000, 465000, 501000], ['Europe', 388000, 402000, 411000], ['APAC', 275000, 298000, 312000], ['Latin America', 142000, 151000, 158000], ['Middle East', 96000, 101000, 108000], ]} colHeaders={['Region', 'Jan 2025', 'Feb 2025', 'Mar 2025']} rowHeaders={true} height="auto" contextMenu={true} mergeCells={true} autoWrapRow={true} autoWrapCol={true} beforeMergeCells={(cellRange) => { logEvent(`beforeMergeCells: rows ${cellRange.from.row}-${cellRange.to.row}, columns ${cellRange.from.col}-${cellRange.to.col}.`); }} afterMergeCells={(cellRange, mergeParent) => { logEvent(`afterMergeCells: merged into ${mergeParent.rowspan} row(s) by ${mergeParent.colspan} column(s).`); }} beforeUnmergeCells={(cellRange) => { logEvent(`beforeUnmergeCells: rows ${cellRange.from.row}-${cellRange.to.row}, columns ${cellRange.from.col}-${cellRange.to.col}.`); }} afterUnmergeCells={(cellRange) => { logEvent(`afterUnmergeCells: rows ${cellRange.from.row}-${cellRange.to.row}, columns ${cellRange.from.col}-${cellRange.to.col}.`); }} licenseKey="non-commercial-and-evaluation" /> </> );};
export default ExampleComponent;Merge and unmerge cells programmatically
To merge or unmerge a range without a user action, call the MergeCells plugin’s merge() and unmerge() methods. Both methods take a visual startRow, startColumn, endRow, and endColumn, the same coordinate space as selectCell().
hot.getPlugin('mergeCells').merge(startRow, startColumn, endRow, endColumn);hot.getPlugin('mergeCells').unmerge(startRow, startColumn, endRow, endColumn);This clears the underlying data the same way as merging through the UI or the mergeCells configuration option. See Effect on the underlying data.
The example below merges and unmerges a footnote row that spans every column, using buttons instead of a manual selection.
import { useRef } from 'react';import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
const ExampleComponent = () => { const hotRef = useRef(null);
const mergeNoteRow = () => { hotRef.current?.hotInstance?.getPlugin('mergeCells').merge(5, 0, 5, 3); };
const unmergeNoteRow = () => { hotRef.current?.hotInstance?.getPlugin('mergeCells').unmerge(5, 0, 5, 3); };
return ( <> <div className="example-controls-container"> <div className="controls"> <button id="example4-merge" className="button button--primary" onClick={mergeNoteRow}> Merge the note row </button> <button id="example4-unmerge" className="button button--primary" onClick={unmergeNoteRow}> Unmerge the note row </button> </div> </div> <HotTable ref={hotRef} data={[ ['North America', 420000, 465000, 501000], ['Europe', 388000, 402000, 411000], ['APAC', 275000, 298000, 312000], ['Latin America', 142000, 151000, 158000], ['Middle East', 96000, 101000, 108000], ['Note: Q1 totals include a one-time currency adjustment.', null, null, null], ]} colHeaders={['Region', 'Jan 2025', 'Feb 2025', 'Mar 2025']} rowHeaders={true} height="auto" contextMenu={true} mergeCells={true} autoWrapRow={true} autoWrapCol={true} licenseKey="non-commercial-and-evaluation" /> </> );};
export default ExampleComponent;import { useRef } from 'react';import { HotTable, HotTableRef } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
const ExampleComponent = () => { const hotRef = useRef<HotTableRef>(null);
const mergeNoteRow = () => { hotRef.current?.hotInstance?.getPlugin('mergeCells').merge(5, 0, 5, 3); };
const unmergeNoteRow = () => { hotRef.current?.hotInstance?.getPlugin('mergeCells').unmerge(5, 0, 5, 3); };
return ( <> <div className="example-controls-container"> <div className="controls"> <button id="example4-merge" className="button button--primary" onClick={mergeNoteRow}> Merge the note row </button> <button id="example4-unmerge" className="button button--primary" onClick={unmergeNoteRow}> Unmerge the note row </button> </div> </div> <HotTable ref={hotRef} data={[ ['North America', 420000, 465000, 501000], ['Europe', 388000, 402000, 411000], ['APAC', 275000, 298000, 312000], ['Latin America', 142000, 151000, 158000], ['Middle East', 96000, 101000, 108000], ['Note: Q1 totals include a one-time currency adjustment.', null, null, null], ]} colHeaders={['Region', 'Jan 2025', 'Feb 2025', 'Mar 2025']} rowHeaders={true} height="auto" contextMenu={true} mergeCells={true} autoWrapRow={true} autoWrapCol={true} licenseKey="non-commercial-and-evaluation" /> </> );};
export default ExampleComponent;Effect on the underlying data
Merging doesn’t only hide the covered cells visually — it clears their values in the underlying data too, whether you merge through the UI, the mergeCells configuration option, or the merge() method. Only the top-left cell of the range keeps its value; every other cell covered by the merge is set to null.
For a range merged at (0, 0) with rowspan: 2 and colspan: 2:
hot.getData();// -> [['Top-left value', null], [null, null]]
hot.getSourceData();// -> [['Top-left value', null], [null, null]] (the same)Both getData() and getSourceData() return null for the covered cells, because the clearing runs through the normal change pipeline: it fires beforeChange and afterChange with source === 'MergeCells' (see Events and hooks: Definition for source argument).
Unmerging does not restore the cleared values. If you need the original values back, keep a copy before merging, or restore them yourself in a beforeUnmergeCells or afterUnmergeCells handler.
Effect on viewport getter methods
With merged cells, the rendered range extends to fit any merged cell that crosses the viewport edge. This is the same expansion that the virtualized option turns off. As a result, the rendered-range getters can return indexes beyond what you see on the screen:
getFirstRenderedVisibleRow(),getLastRenderedVisibleRow(),getFirstRenderedVisibleColumn(), andgetLastRenderedVisibleColumn().AutoRowSize.getFirstVisibleRow()andAutoRowSize.getLastVisibleRow(), which delegate to the rendered-row getters.AutoColumnSize.getFirstVisibleColumn()andAutoColumnSize.getLastVisibleColumn(), which delegate to the rendered-column getters.
For example, a merged cell that spans columns 0 to 100 makes getLastVisibleColumn() return an index near 100, even when the viewport shows far fewer columns.
To read the actual visible viewport, use the fully-visible or partially-visible getters, which ignore the merge-cell expansion:
getFirstFullyVisibleRow(),getLastFullyVisibleRow(),getFirstFullyVisibleColumn(), andgetLastFullyVisibleColumn().getFirstPartiallyVisibleRow(),getLastPartiallyVisibleRow(),getFirstPartiallyVisibleColumn(), andgetLastPartiallyVisibleColumn().
Setting virtualized to true also removes the range expansion, but it is a performance option — the rendered-range getters still include buffered rows and columns outside the viewport.
Behavior during row/column reorder and column freeze
When a merged cell’s underlying rows or columns are reordered (through manualColumnMove, manualRowMove, or manualColumnFreeze), Handsontable follows the merge to the new visual position. Two side effects can occur:
- Auto-split: if the move bisects a merge so the underlying cells are no longer contiguous in the new visual order, the merge is split into separate merges, one per contiguous run. The cross-axis span (
rowspanfor column moves,colspanfor row moves) is preserved on every fragment. - Silent drop of single-cell fragments: any resulting fragment that ends up as a single cell (
rowspan === 1 && colspan === 1) is removed, because a single cell is no longer a merge. TheafterMergeCellshook is not fired for the dropped fragment.
undo and redo restore the pre-move state, including any merges that were split or dropped by the reorder.
Result
Cells at the configured positions are now merged. Users see a single cell spanning multiple rows or columns.
Related keyboard shortcuts
| Windows | macOS | Action | Excel | Sheets |
|---|---|---|---|---|
| Ctrl+M | ⌃+M | Merge or unmerge the selected cells | ✗ | ✗ |
Related API reference
Configuration options
Hooks
Plugins