Custom context menu actions
In this tutorial, you will add custom items to the Handsontable right-click context menu. You will learn how to define custom actions alongside built-in menu items and how to control which built-in items appear.
/* file: app.component.ts */import { Component, ViewChild } from '@angular/core';import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';import { RowObject } from 'handsontable/common';
/* start:skip-in-preview */const data: RowObject[] = [ { task: 'Deploy API v2', assignee: 'Alice Chen', status: 'In Progress', priority: 'High', dueDate: '2026-05-10' }, { task: 'Write unit tests', assignee: 'Bob Smith', status: 'To Do', priority: 'Medium', dueDate: '2026-05-15' }, { task: 'Review PR #142', assignee: 'Carol Davis', status: 'Done', priority: 'Low', dueDate: '2026-04-30' }, { task: 'Fix login timeout', assignee: 'David Lee', status: 'In Progress', priority: 'High', dueDate: '2026-05-08' }, { task: 'Update SSL certs', assignee: 'Eve Martin', status: 'To Do', priority: 'Urgent', dueDate: '2026-05-01' }, { task: 'Migrate DB schema', assignee: 'Frank Wang', status: 'To Do', priority: 'High', dueDate: '2026-05-20' }, { task: 'Refactor auth module', assignee: 'Grace Kim', status: 'In Progress', priority: 'Medium', dueDate: '2026-05-25' }, { task: 'Load test staging', assignee: 'Henry Park', status: 'To Do', priority: 'Low', dueDate: '2026-06-01' },];/* end:skip-in-preview */
const flaggedRows = new Set<number>();
@Component({ standalone: true, imports: [HotTableModule], selector: 'example1-custom-context-menu', template: ` <div> <hot-table [data]="data" [settings]="gridSettings"></hot-table> </div> `,})export class AppComponent { @ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent;
readonly data: RowObject[] = data;
readonly gridSettings: GridSettings = { rowHeaders: true, colHeaders: ['Task', 'Assignee', 'Status', 'Priority', 'Due Date'], columns: [ { data: 'task', width: 200 }, { data: 'assignee', width: 130 }, { data: 'status', width: 110 }, { data: 'priority', width: 90 }, { data: 'dueDate', width: 110 }, ], height: 'auto', width: '100%', autoWrapRow: true, cells(row: number) { if (flaggedRows.has(row)) { return { className: 'ht-flagged-row' }; } return {}; }, contextMenu: { items: { duplicate_row: { name: 'Duplicate row', callback: (_key: string, selection: Array<{ start: { row: number } }>) => { const hot = this.hotTable?.hotInstance;
if (!hot) return;
const row = selection[0].start.row; const rowData = hot.getSourceDataAtRow(hot.toPhysicalRow(row)) as Record<string, unknown>;
hot.alter('insert_row_below', row, 1); hot.populateFromArray(row + 1, 0, [Object.values(rowData)]); }, }, flag_row: { name: (): string => { const row = this.hotTable?.hotInstance?.getSelectedRangeLast()?.from.row;
return flaggedRows.has(row as number) ? 'Unflag row' : 'Flag row'; }, callback: (_key: string, selection: Array<{ start: { row: number } }>) => { const hot = this.hotTable?.hotInstance;
if (!hot) return;
const row = selection[0].start.row;
if (flaggedRows.has(row)) { flaggedRows.delete(row); } else { flaggedRows.add(row); }
hot.render(); }, }, copy_row_as_json: { name: 'Copy row as JSON', callback: (_key: string, selection: Array<{ start: { row: number } }>) => { const hot = this.hotTable?.hotInstance;
if (!hot) return;
const row = selection[0].start.row; const rowData = hot.getSourceDataAtRow(hot.toPhysicalRow(row));
navigator.clipboard.writeText(JSON.stringify(rowData)); }, }, sep1: { name: '---------' }, row_above: { name: 'Insert row above' }, row_below: { name: 'Insert row below' }, remove_row: { name: 'Remove row' }, sep2: { name: '---------' }, undo: { name: 'Undo' }, redo: { name: 'Redo' }, }, }, };}/* end-file */
/* file: app.config.ts */import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';import { registerAllModules } from 'handsontable/registry';import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div> <example1-custom-context-menu></example1-custom-context-menu></div>td.ht-flagged-row { background-color: #fff9c4 !important;}Overview
Difficulty: Beginner Time: ~15 minutes
Handsontable’s context menu supports fully custom items alongside built-in ones. This recipe shows you how to add three practical actions — Duplicate row, Flag row, and Copy row as JSON — and how to keep only the built-in items you want.
What You’ll Build
A project-task grid where right-clicking a row reveals:
- Duplicate row - inserts an exact copy of the row directly below the selected one.
- Flag row / Unflag row - toggles a yellow highlight on the row to mark it for attention.
- Copy row as JSON - writes the row’s data as a JSON string to the clipboard.
- A separator line dividing custom actions from built-in row operations.
- A curated set of built-in items (insert row above/below, remove row, undo, redo) with column-insert items removed.
Before you begin
You need a working Handsontable instance. If you are starting fresh, install it first:
npm install handsontableThen import and register all modules:
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();Set up the data and the flagged-row tracker
const data = [{ task: 'Deploy API v2', assignee: 'Alice Chen', status: 'In Progress', priority: 'High', dueDate: '2026-05-10' },// ...more rows];const flaggedRows = new Set();What’s happening:
datais an array of row objects. Each object maps to one row in the grid.flaggedRowsis a JavaScriptSetthat tracks which visual row indexes are currently flagged. ASetgives you O(1) add, delete, and lookup, which keeps thecellscallback fast even with many rows.
Apply per-row CSS via the
cellscallbackconst hot = new Handsontable(container, {// ...cells(row) {if (flaggedRows.has(row)) {return { className: 'ht-flagged-row' };}},// ...});What’s happening:
- Handsontable calls
cells(row, col, prop)for every cell it renders. Returning an object from this callback merges its properties into the cell’s meta. - Returning
{ className: 'ht-flagged-row' }tells Handsontable to add that CSS class to the<td>element of every cell in the flagged row. - The CSS file defines
.ht-flagged-row { background-color: #fff9c4 !important; }, which produces the yellow highlight. - Because
cellsis called on every render, toggling a row inflaggedRowsand then callinghot.render()is all you need — no direct DOM manipulation required.
- Handsontable calls
Configure the context menu with custom items
contextMenu: {items: {duplicate_row: { name: 'Duplicate row', callback(key, selection) { ... } },flag_row: { name() { ... }, callback(key, selection) { ... } },copy_row_as_json: { name: 'Copy row as JSON', callback(key, selection) { ... } },sep1: '-',row_above: { name: 'Insert row above' },row_below: { name: 'Insert row below' },remove_row: { name: 'Remove row' },sep2: '-',undo: { name: 'Undo' },redo: { name: 'Redo' },},},What’s happening:
- Passing an object to
contextMenu.itemsgives you full control. The keys you list are the only items that appear — anything omitted (includingcol_leftandcol_right) is hidden. - Custom items use any key string that does not clash with built-in keys. Here,
duplicate_row,flag_row, andcopy_row_as_jsonare all user-defined. - Built-in items (
row_above,row_below,remove_row,undo,redo) can be re-declared with a customnameto override the label, or listed as-is to use the default label. '-'(a string with a single hyphen) is the built-in separator token. Any key that maps to'-'renders as a visual divider.
- Passing an object to
Implement “Duplicate row”
duplicate_row: {name: 'Duplicate row',callback(key, selection) {const row = selection[0].start.row;const rowData = hot.getSourceDataAtRow(hot.toPhysicalRow(row));hot.alter('insert_row_below', row, 1);hot.populateFromArray(row + 1, 0, [Object.values(rowData)]);},},What’s happening:
selection[0].start.rowis the visual row index of the right-clicked cell.hot.toPhysicalRow(row)converts the visual index to a physical one. This is needed because sorting or filtering can reorder rows;getSourceDataAtRowexpects a physical index.hot.getSourceDataAtRow(physicalRow)returns the raw source object (e.g.,{ task: '...', assignee: '...' }).hot.alter('insert_row_below', row, 1)inserts one empty row directly below the clicked row.hot.populateFromArray(row + 1, 0, [Object.values(rowData)])fills that new row with the original row’s values.populateFromArrayexpects a 2-D array, so you wrapObject.values(rowData)in an outer array.
Implement “Flag row”
flag_row: {name() {const row = this.getSelectedRangeLast()?.from.row;return flaggedRows.has(row) ? 'Unflag row' : 'Flag row';},callback(key, selection) {const row = selection[0].start.row;if (flaggedRows.has(row)) {flaggedRows.delete(row);} else {flaggedRows.add(row);}hot.render();},},What’s happening:
namecan be a function. Handsontable calls it each time the menu opens, so the label updates dynamically. Insidename,thisis the context menu instance;this.getSelectedRangeLast()?.from.rowreads the hovered row to show the correct label (“Flag row” or “Unflag row”) before the user clicks.- In
callback, you add or remove the row fromflaggedRows, then callhot.render(). The re-render triggers thecellscallback, which returnsclassName: 'ht-flagged-row'for flagged rows and nothing for unflagged ones, so the highlight appears or disappears immediately.
Implement “Copy row as JSON”
copy_row_as_json: {name: 'Copy row as JSON',callback(key, selection) {const row = selection[0].start.row;const rowData = hot.getSourceDataAtRow(hot.toPhysicalRow(row));navigator.clipboard.writeText(JSON.stringify(rowData));},},What’s happening:
JSON.stringify(rowData)serializes the source object for that row (e.g.,{"task":"Deploy API v2","assignee":"Alice Chen",...}).navigator.clipboard.writeText(...)writes the string to the system clipboard asynchronously. It requires a secure context (HTTPS or localhost) and returns aPromise; you can add.catch(...)to handle permission errors in production.
How It Works - Complete Flow
- User right-clicks a cell. Handsontable opens the context menu and calls each item’s
namefunction (if it is a function) to render labels. - The menu shows the three custom items, a separator, and the selected built-in items. Column-insert items are absent because they were not listed.
- Duplicate row:
altershifts existing rows down, thenpopulateFromArraycopies the source data into the new row. - Flag row / Unflag row: The
flaggedRowsSet is updated,hot.render()fires thecellscallback for every visible cell, and the CSS class is added or removed. - Copy row as JSON: Source data is serialized and written to the clipboard without modifying the grid.
What you learned
- Pass an object to
contextMenu.itemsto control exactly which items appear. Omitted keys — including built-in ones likecol_left— are hidden. - Use a string key like
'duplicate_row'for custom items. The key must not clash with built-in keys. callback(key, selection)receives the item key and an array of selection ranges.selection[0].start.rowgives you the right-clicked visual row.- Convert visual to physical row with
hot.toPhysicalRow(row)before callinghot.getSourceDataAtRow(). - Use
nameas a function to return a dynamic label based on current state. - The
cellscallback +hot.render()is the idiomatic way to apply per-row styling without touching the DOM directly. '-'as an item value inserts a visual separator.
Next steps
- Add a
disabled()function to any item to make it conditionally unavailable. Returntrueto grey out the item and prevent its callback from firing. - Explore the Context menu guide for the full list of built-in item keys and advanced configuration.
- To add the same custom actions to the column dropdown menu, configure
dropdownMenu.itemsusing the same structure.