How to use icons in cells
Render an icon from the icon pack inside a cell, and wire up an icon-only control so it stays accessible to screen reader users.
Prerequisites
- A grid with a custom cell renderer.
- The
@handsontable/spreadsheet-iconspackage, or any inline SVG icon of your own. - For the third-party icon set section, a Lucide package.
Steps
-
Install the icon pack.
Terminal window npm install @handsontable/spreadsheet-iconsYou can also copy a single icon’s SVG markup directly from the icon pack page — click any icon in the catalog to copy its name, then look up its markup in the downloaded package.
-
Render the icon inside a custom renderer.
Build the icon markup once, then swap it into the cell based on the cell’s value. This example renders a “flag” icon that toggles between a filled and an empty state.
-
Add an accessible name to the control, not the icon.
The icon itself — an inline
<svg>— has no accessible name. If you wrap it in a clickable element (a<button>, or a<div>withrole="button"), setaria-labelon that element instead, and update it whenever the icon’s state changes.
/* file: app.component.ts */import { Component } from '@angular/core';import { GridSettings, HotTableModule } from '@handsontable/angular-wrapper';import Handsontable from 'handsontable/base';import type { BaseRenderer } from 'handsontable/renderers';
interface Task { task: string; assignee: string; dueDate: string; flagged: boolean;}
// Icons from the `@handsontable/spreadsheet-icons` package - see the "Icon// pack" reference page for the full set.// `currentColor` picks up the button's text color, so the icon stays visible// in both light and dark themes instead of a hardcoded color disappearing// against the background.const FLAG_FULL_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6,4.8H5V20H6V13.21c4.18,2.51,8.37-2.51,12.56,0V4.8C14.38,2.29,10.19,7.31,6,4.8Z"/></svg>';const FLAG_EMPTY_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor" opacity="0.6"><path d="M15.87,4.08c-2.41,0-4.83,1.45-7.25,1.45A5,5,0,0,1,6,4.81v0H5V20H6V13.29A5,5,0,0,0,8.62,14c2.42,0,4.84-1.45,7.25-1.45a5,5,0,0,1,2.66.72V4.8A5,5,0,0,0,15.87,4.08Zm1.66,7.68a6.45,6.45,0,0,0-1.66-.21,13.64,13.64,0,0,0-3.91.77A12.11,12.11,0,0,1,8.62,13,4.17,4.17,0,0,1,7,12.67l-1-.43V5.9l1,.41a6,6,0,0,0,1.66.22,13.51,13.51,0,0,0,3.91-.77,12.11,12.11,0,0,1,3.34-.68,4,4,0,0,1,1.66.33Z"/></svg>';
// The icon itself has no accessible name, so the button carries an// `aria-label` that describes what clicking it does.const flagRenderer: BaseRenderer = ( instance: Handsontable, td: HTMLTableCellElement, row: number, _col: number, _prop: string | number, value: Handsontable.CellValue) => { const taskName = String(instance.getDataAtCell(row, 0)); const button = document.createElement('button');
button.type = 'button'; button.style.cssText = 'background: none; border: none; cursor: pointer; padding: 0.25rem;'; button.innerHTML = value ? FLAG_FULL_ICON : FLAG_EMPTY_ICON; button.setAttribute( 'aria-label', value ? `Remove the follow-up flag from ${taskName}` : `Flag ${taskName} for follow-up` ); button.addEventListener('click', () => { instance.setDataAtCell(row, 3, !value); });
td.innerText = ''; td.appendChild(button);
return td;};
@Component({ standalone: true, imports: [HotTableModule], selector: 'app-example1', template: ` <hot-table [data]="data" [settings]="gridSettings"></hot-table> `,})export class AppComponent { readonly data: Task[] = [ { task: 'Update API docs', assignee: 'Ana García', dueDate: '2025-06-30', flagged: true }, { task: 'Deploy hotfix', assignee: 'James Okafor', dueDate: '2025-06-18', flagged: false }, { task: 'Review pull request', assignee: 'Li Wei', dueDate: '2025-06-20', flagged: false }, { task: 'Write release notes', assignee: 'Sara Nowak', dueDate: '2025-06-25', flagged: true }, { task: 'Fix flaky test', assignee: 'Marco Rossi', dueDate: '2025-06-22', flagged: false }, ];
readonly gridSettings: GridSettings = { colHeaders: ['Task', 'Assignee', 'Due date', 'Flagged'], columns: [ { data: 'task' }, { data: 'assignee' }, { data: 'dueDate', type: 'date', dateFormat: { year: 'numeric', month: '2-digit', day: '2-digit' } }, { data: 'flagged', renderer: flagRenderer, className: 'htCenter' }, ], height: 'auto', autoWrapRow: true, autoWrapCol: true, };}/* 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';
// register Handsontable's modulesregisterAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div> <app-example1></app-example1></div>Click a flag icon to toggle it. Each click updates both the underlying cell value (through setDataAtCell()) and the button’s aria-label, so the accessible name always matches what the icon shows.
Use a third-party icon set
An icon library reaches the cell in one of two ways, and which one you get depends on the framework:
- A DOM scan. You write a placeholder element, and the library replaces it with an
<svg>. Lucide’s vanilla package uses<i data-lucide="flag"></i>plus acreateIcons()call. - A component. The icon is a component you render directly, such as
<Flag />. This needs a renderer that returns framework markup rather than DOM nodes.
Install the Angular package, then pass a renderer component to the column:
npm install @lucide/angularThe component extends HotCellRendererComponent and receives the cell’s value and coordinates as inputs, so the icon goes in the template and no createIcons() scan runs at all. Each Lucide icon is its own standalone component applied as an attribute on an <svg> element, so import the icons you use and list them in the component’s imports.
/* file: app.component.ts */import { ChangeDetectionStrategy, Component } from '@angular/core';import { GridSettings, HotCellRendererComponent, HotTableModule } from '@handsontable/angular-wrapper';import { LucideFlag, LucideFlagOff } from '@lucide/angular';
interface Task { task: string; assignee: string; dueDate: string; flagged: boolean;}
// A renderer component receives the cell's value and coordinates, so the// Lucide icon goes in the template. No `data-lucide` placeholder and no// `createIcons()` scan - Angular renders the `<svg>` itself.@Component({ selector: 'app-flag-renderer', standalone: true, imports: [LucideFlag, LucideFlagOff], // Each icon is its own standalone component, applied as an attribute on an // `<svg>` element. The icon has no accessible name, so the button carries an // `aria-label` that describes what clicking it does. `currentColor` is // Lucide's default stroke, so the icon stays visible in both themes. template: ` <button type="button" [attr.aria-label]="label" style="background: none; border: none; cursor: pointer; padding: 0.25rem;" (click)="toggle()" > @if (value) { <svg lucideFlag size="18"></svg> } @else { <svg lucideFlagOff size="18"></svg> } </button> `, changeDetection: ChangeDetectionStrategy.OnPush,})export class FlagRendererComponent extends HotCellRendererComponent<boolean> { get label(): string { const taskName = String(this.instance.getDataAtCell(this.row, 0));
return this.value ? `Remove the follow-up flag from ${taskName}` : `Flag ${taskName} for follow-up`; }
toggle(): void { this.instance.setDataAtCell(this.row, 3, !this.value); }}
@Component({ standalone: true, imports: [HotTableModule], selector: 'app-example2', template: ` <hot-table [data]="data" [settings]="gridSettings"></hot-table> `,})export class AppComponent { readonly data: Task[] = [ { task: 'Update API docs', assignee: 'Ana García', dueDate: '2025-06-30', flagged: true }, { task: 'Deploy hotfix', assignee: 'James Okafor', dueDate: '2025-06-18', flagged: false }, { task: 'Review pull request', assignee: 'Li Wei', dueDate: '2025-06-20', flagged: false }, { task: 'Write release notes', assignee: 'Sara Nowak', dueDate: '2025-06-25', flagged: true }, { task: 'Fix flaky test', assignee: 'Marco Rossi', dueDate: '2025-06-22', flagged: false }, ];
readonly gridSettings: GridSettings = { colHeaders: ['Task', 'Assignee', 'Due date', 'Flagged'], columns: [ { data: 'task' }, { data: 'assignee' }, { data: 'dueDate', type: 'date', dateFormat: { year: 'numeric', month: '2-digit', day: '2-digit' } }, { data: 'flagged', renderer: FlagRendererComponent, className: 'htCenter' }, ], height: 'auto', autoWrapRow: true, autoWrapCol: true, };}/* 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';
// register Handsontable's modulesregisterAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div> <app-example2></app-example2></div>The accessibility rule does not change with the icon source: the aria-label stays on the button, never on the icon.
Result
You have a cell that renders an icon based on its data, with a click handler that updates the grid, and an accessible name that a screen reader announces correctly in both icon states.