Wrap Handsontable in a web component
In this tutorial, you will wrap Handsontable in a custom element that owns its own shadow root. It covers only the glue between the grid and the element: getting the stylesheets to both sides of the shadow boundary, passing data and settings in through methods, emitting cell edits out as DOM events, and destroying the instance when the element leaves the page. It assumes you already write custom elements.
Overview
Difficulty: Intermediate
Time: ~20 minutes
A custom element gives the grid one tag, a few methods, and one event, and the same tag then works in a plain page or inside a framework component.
Handsontable works across a shadow boundary on its own, so clicking, the cell editors, copy and paste, and the context menu need no forwarding code from you - see the Shadow DOM guide for what it handles there.
What you’ll build
A <hot-grid> element with a small, framework-agnostic contract:
setData()andsetSettings()methods that update a live grid instead of recreating it, withgetData(),getSettings(), andgetInstance()reading straight from it.- A
cell-changeevent that carries each edit to the host page. - Stylesheets on both sides of the shadow boundary: a constructed sheet inside the root, a plain import for the menus outside it.
Before you begin
Scaffold a vanilla project and add the grid:
npm create vite@latest hot-grid-element -- --template vanilla-tscd hot-grid-elementnpm install handsontableDefine the element and its styles
The grid needs a container inside the shadow root, and a
:hostdisplay so the element has a box to render into.index.htmlputs the tag on the page, so every later step has something live to change:src/main.ts import baseStyles from 'handsontable/styles/handsontable.min.css?inline';import themeStyles from 'handsontable/styles/ht-theme-main.min.css?inline';// The menus mount in a portal on document.body, outside the shadow root.import 'handsontable/styles/handsontable.min.css';import 'handsontable/styles/ht-theme-main.min.css';const gridStyles = new CSSStyleSheet();gridStyles.replaceSync(`${baseStyles}${themeStyles}:host { display: block; }`);class HotGridElement extends HTMLElement {#container: HTMLDivElement;constructor() {super();const shadowRoot = this.attachShadow({ mode: 'open' });shadowRoot.adoptedStyleSheets = [gridStyles];this.#container = document.createElement('div');shadowRoot.append(this.#container);}}customElements.define('hot-grid', HotGridElement);declare global {interface HTMLElementTagNameMap {'hot-grid': HotGridElement;}}index.html <hot-grid></hot-grid><script type="module" src="/src/main.ts"></script>The
?inlineimports give the constructed sheet its text:handsontable.min.cssfor structure, the theme stylesheet for the visible design. Without the plain pair in the document, every menu opens unstyled.The page stays blank for now: the element upgrades and its shadow root is styled, but no grid exists until Step 2 creates one.
Create the grid and emit its edits
Create the instance in
connectedCallback(), and translate the one hook the host page cares about into a DOM event:src/main.ts import Handsontable from 'handsontable';15 collapsed linesimport baseStyles from 'handsontable/styles/handsontable.min.css?inline';import themeStyles from 'handsontable/styles/ht-theme-main.min.css?inline';// The menus mount in a portal on document.body, outside the shadow root.import 'handsontable/styles/handsontable.min.css';import 'handsontable/styles/ht-theme-main.min.css';const gridStyles = new CSSStyleSheet();gridStyles.replaceSync(`${baseStyles}${themeStyles}:host { display: block; }`);interface HotGridCellChange {row: number;physicalRow: number;prop: Handsontable.CellChange[1];oldValue: Handsontable.CellValue;newValue: Handsontable.CellValue;source?: Handsontable.ChangeSource;}class HotGridElement extends HTMLElement {#instance: Handsontable | null = null;13 collapsed lines#container: HTMLDivElement;constructor() {super();const shadowRoot = this.attachShadow({ mode: 'open' });shadowRoot.adoptedStyleSheets = [gridStyles];this.#container = document.createElement('div');shadowRoot.append(this.#container);}#createGrid(): void {const instance = new Handsontable(this.#container, {themeName: 'ht-theme-main',rowHeaders: true,licenseKey: 'non-commercial-and-evaluation',afterChange: (changes, source) => {if (!changes || source === 'loadData') {return;}changes.forEach(([row, prop, oldValue, newValue]) => {this.dispatchEvent(new CustomEvent<HotGridCellChange>('cell-change', {detail: {row,physicalRow: instance.toPhysicalRow(row),prop,oldValue,newValue,source,},bubbles: true,composed: true,}));});},});this.#instance = instance;}connectedCallback(): void {if (!this.#instance) {this.#createGrid();}}8 collapsed lines}customElements.define('hot-grid', HotGridElement);declare global {interface HTMLElementTagNameMap {'hot-grid': HotGridElement;}interface HTMLElementEventMap {'cell-change': CustomEvent<HotGridCellChange>;}}composed: trueis what lets the event leave the shadow root - without it a listener on the host page never fires. Skipping theloadDatasource keeps the first data load from reporting every cell as an edit.cell-changecarries both indexes, becauserowis visual and moves when the user sorts - index your store withphysicalRow. See Understanding data and indexes.Replace
licenseKeywith your commercial key before production use. See License key.Expose the grid through methods
The host reads and writes through methods that go straight to the instance, so the element stores no copy of the data:
src/main.ts 16 collapsed linesimport Handsontable from 'handsontable';import baseStyles from 'handsontable/styles/handsontable.min.css?inline';import themeStyles from 'handsontable/styles/ht-theme-main.min.css?inline';// The menus mount in a portal on document.body, outside the shadow root.import 'handsontable/styles/handsontable.min.css';import 'handsontable/styles/ht-theme-main.min.css';const gridStyles = new CSSStyleSheet();gridStyles.replaceSync(`${baseStyles}${themeStyles}:host { display: block; }`);type HotGridRow = Handsontable.RowObject | Handsontable.CellValue[];7 collapsed linesinterface HotGridCellChange {row: number;physicalRow: number;prop: Handsontable.CellChange[1];oldValue: Handsontable.CellValue;newValue: Handsontable.CellValue;source?: Handsontable.ChangeSource;}function cloneRows(rows: HotGridRow[]): HotGridRow[] {try {return structuredClone(rows);} catch (error) {throw new Error('hot-grid: data must be structured-cloneable. Reactive proxies, cyclic ' +'references, functions, and BigInt are not.',{ cause: error },);}53 collapsed lines}class HotGridElement extends HTMLElement {#instance: Handsontable | null = null;#container: HTMLDivElement;constructor() {super();const shadowRoot = this.attachShadow({ mode: 'open' });shadowRoot.adoptedStyleSheets = [gridStyles];this.#container = document.createElement('div');shadowRoot.append(this.#container);}#createGrid(): void {const instance = new Handsontable(this.#container, {themeName: 'ht-theme-main',rowHeaders: true,licenseKey: 'non-commercial-and-evaluation',afterChange: (changes, source) => {if (!changes || source === 'loadData') {return;}changes.forEach(([row, prop, oldValue, newValue]) => {this.dispatchEvent(new CustomEvent<HotGridCellChange>('cell-change', {detail: {row,physicalRow: instance.toPhysicalRow(row),prop,oldValue,newValue,source,},bubbles: true,composed: true,}));});},});this.#instance = instance;}connectedCallback(): void {if (!this.#instance) {this.#createGrid();}}getData(): ReturnType<Handsontable['getSourceData']> {return this.#instance?.getSourceData() ?? [];}setData(rows: HotGridRow[]): void {this.#instance?.updateSettings({ data: cloneRows(rows) });}getSettings(): Handsontable.GridSettings {return this.#instance?.getSettings() ?? {};}setSettings(settings: Handsontable.GridSettings): void {this.#instance?.updateSettings(settings);}getInstance(): Handsontable | null {return this.#instance;}13 collapsed lines}customElements.define('hot-grid', HotGridElement);declare global {interface HTMLElementTagNameMap {'hot-grid': HotGridElement;}interface HTMLElementEventMap {'cell-change': CustomEvent<HotGridCellChange>;}}Handsontable writes edits into the array it is given, so
setData()clones what the host passes in rather than letting the grid edit the host’s own state.Clean up on disconnect
A grid that is never destroyed keeps its listeners and its resize observer alive, which leaks on any page that swaps views without a reload:
src/main.ts connectedCallback(): void {if (!this.#instance) {this.#createGrid();}}disconnectedCallback(): void {queueMicrotask(() => {if (this.isConnected) {return;}this.#instance?.destroy();this.#instance = null;});}getData(): ReturnType<Handsontable['getSourceData']> {return this.#instance?.getSourceData() ?? [];}setData(rows: HotGridRow[]): void {this.#instance?.updateSettings({ data: cloneRows(rows) });}Handsontable reads the container’s root node once, when the instance is created, and that single check drives the
ht-shadow-domclass and the shadow-root clipboard listeners. Moving a live grid into or out of a shadow root leaves those bound to the wrong tree, so destroy and recreate rather than reparenting.The microtask is what makes an ordinary DOM move survivable.
insertBeforeandappendon an already-connected element firedisconnectedCallbackand thenconnectedCallback, so destroying synchronously throws away the instance - and with it the selection and any edit the grid has not written back - on a plain reorder. Deferring one microtask lets the reconnect cancel the teardown.Use the element
The tag has been on the page since Step 1. Fill it at the end of
src/main.ts, below the class:src/main.ts customElements.define('hot-grid', HotGridElement);declare global {interface HTMLElementTagNameMap {'hot-grid': HotGridElement;}interface HTMLElementEventMap {'cell-change': CustomEvent<HotGridCellChange>;}}const grid = document.querySelector('hot-grid');grid?.setData([{ sku: 'SKU-4821', product: 'Thermal camera', quantity: 142, supplier: 'Harbor Goods' },{ sku: 'SKU-0093', product: 'Cable tester', quantity: 67, supplier: 'Alpine Supply Co.' },{ sku: 'SKU-1147', product: 'Torque wrench', quantity: 0, supplier: 'Harbor Goods' },{ sku: 'SKU-2250', product: 'Label printer', quantity: 38, supplier: 'Vertex Industries' },{ sku: 'SKU-3390', product: 'Impact driver', quantity: 91, supplier: 'Alpine Supply Co.' },]);grid?.setSettings({colHeaders: ['SKU', 'Product', 'Quantity', 'Supplier'],columns: [{ data: 'sku' },{ data: 'product' },{ data: 'quantity', type: 'numeric' },{ data: 'supplier' },],});grid?.addEventListener('cell-change', (event) => {const { physicalRow, prop, newValue } = event.detail;console.log(physicalRow, prop, newValue);});
Related
What you learned
- The shadow root adopts the grid’s stylesheets as a constructed sheet, and the document still needs them for the menus.
- Data and settings go in through methods, forwarded to
updateSettings()so a live grid is not recreated.
Next steps
- Explore Run Handsontable in a Salesforce Lightning Web Component for the same pattern on a platform that owns the component lifecycle.