Skip to content

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.

TypeScript
/* 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 */
HTML
<div>
<example1-custom-context-menu></example1-custom-context-menu>
</div>
CSS
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:

Terminal window
npm install handsontable

Then import and register all modules:

import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
registerAllModules();
  1. 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:

    • data is an array of row objects. Each object maps to one row in the grid.
    • flaggedRows is a JavaScript Set that tracks which visual row indexes are currently flagged. A Set gives you O(1) add, delete, and lookup, which keeps the cells callback fast even with many rows.
  2. Apply per-row CSS via the cells callback

    const 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 cells is called on every render, toggling a row in flaggedRows and then calling hot.render() is all you need — no direct DOM manipulation required.
  3. 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.items gives you full control. The keys you list are the only items that appear — anything omitted (including col_left and col_right) is hidden.
    • Custom items use any key string that does not clash with built-in keys. Here, duplicate_row, flag_row, and copy_row_as_json are all user-defined.
    • Built-in items (row_above, row_below, remove_row, undo, redo) can be re-declared with a custom name to 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.
  4. 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:

    1. selection[0].start.row is the visual row index of the right-clicked cell.
    2. hot.toPhysicalRow(row) converts the visual index to a physical one. This is needed because sorting or filtering can reorder rows; getSourceDataAtRow expects a physical index.
    3. hot.getSourceDataAtRow(physicalRow) returns the raw source object (e.g., { task: '...', assignee: '...' }).
    4. hot.alter('insert_row_below', row, 1) inserts one empty row directly below the clicked row.
    5. hot.populateFromArray(row + 1, 0, [Object.values(rowData)]) fills that new row with the original row’s values. populateFromArray expects a 2-D array, so you wrap Object.values(rowData) in an outer array.
  5. 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:

    • name can be a function. Handsontable calls it each time the menu opens, so the label updates dynamically. Inside name, this is the context menu instance; this.getSelectedRangeLast()?.from.row reads 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 from flaggedRows, then call hot.render(). The re-render triggers the cells callback, which returns className: 'ht-flagged-row' for flagged rows and nothing for unflagged ones, so the highlight appears or disappears immediately.
  6. 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 a Promise; you can add .catch(...) to handle permission errors in production.

How It Works - Complete Flow

  1. User right-clicks a cell. Handsontable opens the context menu and calls each item’s name function (if it is a function) to render labels.
  2. 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.
  3. Duplicate row: alter shifts existing rows down, then populateFromArray copies the source data into the new row.
  4. Flag row / Unflag row: The flaggedRows Set is updated, hot.render() fires the cells callback for every visible cell, and the CSS class is added or removed.
  5. 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.items to control exactly which items appear. Omitted keys — including built-in ones like col_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.row gives you the right-clicked visual row.
  • Convert visual to physical row with hot.toPhysicalRow(row) before calling hot.getSourceDataAtRow().
  • Use name as a function to return a dynamic label based on current state.
  • The cells callback + 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. Return true to 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.items using the same structure.