Skip to content

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 },
],
};
Vue
<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.

Vue
<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.

Vue
<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.

Vue
<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.

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:

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:

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 (rowspan for column moves, colspan for 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. The afterMergeCells hook 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.

WindowsmacOSActionExcelSheets
Ctrl+M+MMerge or unmerge the selected cells

Configuration options

Hooks

Plugins