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().
const hotSettings = { 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 }, ],};<script setup lang="ts">import { ref } from 'vue';import { HotTable } from '@handsontable/vue3';import { registerAllModules } from 'handsontable/registry';import type { GridSettings } from 'handsontable/settings';
registerAllModules();
const data = new Array(100) .fill(null) .map((_, row) => new Array(50) .fill(null) .map((_, column) => `${row}, ${column}`) );
const hotSettings = ref<GridSettings>({ 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',});</script>
<template> <div id="example1"> <HotTable :settings="hotSettings" /> </div></template>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:
const hotSettings = { 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.
<script setup lang="ts">import { ref } from 'vue';import { HotTable } from '@handsontable/vue3';import { registerAllModules } from 'handsontable/registry';import type { GridSettings } from 'handsontable/settings';
registerAllModules();
const data = new Array(50) .fill(null) .map((_, row) => new Array(500) .fill(null) .map((_, column) => `${row}, ${column}`) );
const hotSettings = ref<GridSettings>({ 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',});</script>
<template> <div id="example2"> <HotTable :settings="hotSettings" /> </div></template>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.
<script setup lang="ts">import { ref } from 'vue';import { HotTable } from '@handsontable/vue3';import { registerAllModules } from 'handsontable/registry';import type { GridSettings } from 'handsontable/settings';
registerAllModules();
const output = ref('');
const logEvent = (message: string) => { output.value = `${message}\n${output.value}`;};
const hotSettings: GridSettings = { 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, licenseKey: 'non-commercial-and-evaluation', 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}.`); },};</script>
<template> <div id="example3"> <output class="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 :settings="hotSettings" /> </div></template>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.
<script setup lang="ts">import { useTemplateRef } from 'vue';import { HotTable } from '@handsontable/vue3';import { registerAllModules } from 'handsontable/registry';import type { GridSettings } from 'handsontable/settings';
registerAllModules();
const hotRef = useTemplateRef<InstanceType<typeof HotTable>>('hotRef');
const hotSettings: GridSettings = { 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',};
function mergeNoteRow() { hotRef.value?.hotInstance?.getPlugin('mergeCells').merge(5, 0, 5, 3);}
function unmergeNoteRow() { hotRef.value?.hotInstance?.getPlugin('mergeCells').unmerge(5, 0, 5, 3);}</script>
<template> <div id="example4"> <div class="example-controls-container"> <div class="controls"> <button id="example4-merge" class="button button--primary" @click="mergeNoteRow"> Merge the note row </button> <button id="example4-unmerge" class="button button--primary" @click="unmergeNoteRow"> Unmerge the note row </button> </div> </div> <HotTable ref="hotRef" :settings="hotSettings" /> </div></template>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.
Re-applying the same configuration
Re-applying a mergeCells value through updateSettings() clears only the cells that still hold a value. A range whose cells are already empty changes no data, so it fires no beforeChange or afterChange event.
This depends on the clearing write reaching the data. If you cancel it — by returning false from beforeChange, or with a validator that rejects null while allowInvalid is false — the covered cells keep their values, and every re-apply tries to clear them again.
This matters when a framework wrapper resends every option on each render. React and Angular do. Without it, an app that writes those events back into a store keeps receiving changes for values that never changed, and the two can keep triggering each other.
Nothing else about the clearing changes. A range still clears its covered cells the first time you apply it, even where those cells are already empty. And if new values arrive in a covered range — because you passed new data, or because sorting, filtering, or a row move brought other rows under the range — the next re-apply clears them, then stays quiet:
hot.updateSettings({ data: [['SKU-4821', 'Stainless Steel Water Bottle'], ['SKU-0093', 'Wireless Mouse']], mergeCells: [{ row: 0, col: 0, rowspan: 2, colspan: 2 }], // unchanged});
hot.getDataAtCell(0, 1); // -> null, cleared as usualCopying and pasting over merged cells
Pasting a block of more than one cell over a merged range unmerges that range, and every pasted value becomes visible. Excel and Google Sheets behave the same way: a block with its own rows and columns cannot fit inside a single merged cell, so the merge gives way.
Every merged range the pasted block reaches is unmerged, not only the one you selected. A paste fills the larger of the copied block and the selected range, so it can reach past your selection and clip a neighboring merge.
Pasting a single value leaves the merge in place. A single value carries no structure of its own, so it lands in the merged range’s top-left cell and the covered cells stay empty.
The merge geometry travels with the paste’s own undo entry, so undo() restores the pasted values and the merged ranges together rather than in two steps. A validator that corrects a pasted value adds an undo entry of its own for each cell it corrects, as it does for any other write; those entries revert only their value and leave the merged ranges alone.
One thing this does not do: copying does not carry the merge. A merged range copies as its top-left value plus empty cells, so pasting it elsewhere creates no merge. Pasted HTML rowspan and colspan attributes are flattened the same way: the value lands in the top-left cell of the span and the covered cells are set to null.
To keep a merged range intact, cancel the paste from beforePaste. Returning false there stops the whole paste, so nothing is written and no merge is dropped:
new Handsontable(container, { mergeCells: [{ row: 0, col: 0, rowspan: 2, colspan: 2 }], beforePaste(data, coords) { const isSingleValue = data.length === 1 && data[0].length === 1;
if (isSingleValue) { return; // a single value never breaks a merge, so let it through }
const clipboardRows = data.length; const clipboardColumns = Math.max(...data.map(row => row.length));
// Measure the area the paste writes, not the selected area: a paste fills the larger of the // copied block and the selection on each axis, so it can reach past the selection. Scan every // cell of that area, because `rowspan` and `colspan` are set only on a merged range's // top-left cell. const touchesMergedRange = coords.some(({ startRow, startCol, endRow, endCol }) => { const lastRow = startRow + Math.max(clipboardRows, endRow - startRow + 1) - 1; const lastColumn = startCol + Math.max(clipboardColumns, endCol - startCol + 1) - 1;
for (let row = startRow; row <= lastRow; row += 1) { for (let col = startCol; col <= lastColumn; col += 1) { const { rowspan, colspan } = this.getCellMetaTransient(row, col);
if (rowspan > 1 || colspan > 1) { return true; } } }
return false; });
if (touchesMergedRange) { return false; } },});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. - Rows removed from view count as spanned: a fragment that shows a single cell only because the rest of its rows are removed from view is kept, and it spans them again when they come back. This holds for
manualRowMove,manualColumnMove, andmanualColumnFreezealike. The one exception is a row move of a merged cell whose rows a sort has separated (see the next section).
undo and redo restore the row and column order the reorder changed. They do not restore merged cells: an undo replays the opposite move, so a merged cell the reorder split stays split, and one it dropped stays dropped. Merge the cells again if you need the original merged cell back.
Behavior when rows inside a merge are removed from view
Some features remove rows from the grid entirely: filters, trimRows, and collapsing a parent row of nestedRows. A removed row has no position in the grid at all, so a merged cell that covers one spans fewer rows than it did:
- The merged cell moves to the first of its rows that is still shown, and spans only the rows of its own that remain. It never grows over the rows below it.
- When none of its rows is shown, the merged cell is not displayed.
- When the rows come back, the merged cell spans them again. Nothing about the merge is lost while its rows are away. A merged cell you create while rows are already removed from view covers only the rows you could see, and does not grow when the rest come back.
- When a row move splits a merged cell while some of its rows are removed from view, each removed row stays with the fragment holding the merged cell’s own rows next to it in the grid’s row order, which is the order the rows take when they come back. A removed row never crosses a row the merged cell does not cover: when the move places such a row between the merged cell’s own rows, the removed rows on each side of it stay on that side, and a removed row with no fragment reachable that way is lost.
- A merged cell whose rows a sort has separated is the exception. It still spans one block, so that block reaches over rows the merged cell does not cover, and a row move that breaks the block cannot tell which fragment owns which row. Each fragment then covers only the rows it shows, and the rows removed from view are not restored. A single-column merged cell disappears altogether in this case, because every fragment it leaves behind is a single cell. Sort the column back, or clear the filter, before you move the rows.
hiddenRows works differently. A hidden row keeps its position, so a merged cell spanning one keeps its configured rowspan and simply draws over less space.
One limitation applies to undo. Unmerging a merged cell whose rows are all hidden but one records only the single cell you can see, which is not a merged cell, so undoing that unmerge restores nothing. Expand or unfilter the rows first if you want the unmerge to be reversible.
Keyboard navigation over a merged cell
A merged cell behaves as a single cell at its top-left corner. When you move the selection onto a merged cell — with the arrow keys or a mouse click — the whole merged cell is highlighted. When you then leave it to the left or right with a non-Tab horizontal move — an arrow key, the editor’s arrow-key exit, or Enter when enterMoves is configured to step horizontally — the selection lands on the top row of the merged cell, whichever row you entered it from. Entering a merged cell from below and leaving it sideways lands on the same row as entering it from above, so horizontal navigation stays consistent. When the top row is hidden, the selection lands on the merged cell’s topmost visible row.
Vertical navigation keeps the column you were moving along, and the Tab and Shift+Tab keys keep the row they cycle along, so neither is affected.
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
Microsoft and Excel are registered trademarks of Microsoft Corporation. Google Sheets is a trademark of Google LLC.