Skip to content

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

Steps

  1. Install the icon pack.

    Terminal window
    npm install @handsontable/spreadsheet-icons

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

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

  3. 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> with role="button"), set aria-label on that element instead, and update it whenever the icon’s state changes.

JavaScript
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
// Register all Handsontable's modules.
registerAllModules();
const data = [
{ 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 },
];
// 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>';
const ExampleComponent = () => {
// The icon itself has no accessible name, so the button carries an
// `aria-label` that describes what clicking it does.
const flagRenderer = (instance, td, row, _col, _prop, value) => {
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;
};
return (
<HotTable
data={data}
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}
licenseKey="non-commercial-and-evaluation"
/>
);
};
export default ExampleComponent;
TypeScript
import { HotTable } from '@handsontable/react-wrapper';
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
import type { BaseRenderer } from 'handsontable/renderers';
// Register all Handsontable's modules.
registerAllModules();
interface Task {
task: string;
assignee: string;
dueDate: string;
flagged: boolean;
}
const 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 },
];
// 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>';
const ExampleComponent = () => {
// 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;
};
return (
<HotTable
data={data}
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}
licenseKey="non-commercial-and-evaluation"
/>
);
};
export default ExampleComponent;

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 a createIcons() 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 React package, then pass a renderer component to the column:

Terminal window
npm install lucide-react

The component receives the cell’s value and coordinates, so the icon goes in as JSX and no createIcons() scan runs at all.

JavaScript
import { HotTable, HotColumn } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import { Flag, FlagOff } from 'lucide-react';
// Register all Handsontable's modules.
registerAllModules();
const data = [
{ 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 },
];
// A renderer component receives the cell's value and coordinates, so the
// Lucide icon goes in as JSX. No `data-lucide` placeholder and no
// `createIcons()` scan - React renders the `<svg>` itself.
//
// Pass a renderer component through `<HotColumn renderer={...}>`, not through
// a `renderer` key in the `columns` array. Only the prop is wrapped for React;
// a component in `columns` reaches the grid as a plain renderer function and
// is called with `(instance, TD, row, ...)` instead of a props object.
const FlagCell = ({ instance, row, value }) => {
const taskName = String(instance.getDataAtCell(row, 0));
const flagged = Boolean(value);
// The icon has no accessible name, so the button carries an `aria-label`
// that describes what clicking it does.
const label = flagged ? `Remove the follow-up flag from ${taskName}` : `Flag ${taskName} for follow-up`;
return (
<button
type="button"
aria-label={label}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.25rem' }}
onClick={() => instance.setDataAtCell(row, 3, !flagged)}
>
{/* `currentColor` is Lucide's default stroke, so the icon stays visible
in both light and dark themes. */}
{flagged ? <Flag size={18} /> : <FlagOff size={18} />}
</button>
);
};
const ExampleComponent = () => {
return (
<HotTable
data={data}
colHeaders={['Task', 'Assignee', 'Due date', 'Flagged']}
height="auto"
autoWrapRow={true}
autoWrapCol={true}
licenseKey="non-commercial-and-evaluation"
>
<HotColumn data="task" />
<HotColumn data="assignee" />
<HotColumn data="dueDate" type="date" dateFormat={{ year: 'numeric', month: '2-digit', day: '2-digit' }} />
<HotColumn data="flagged" className="htCenter" renderer={FlagCell} />
</HotTable>
);
};
export default ExampleComponent;
TypeScript
import { HotTable, HotColumn, HotRendererProps } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import { Flag, FlagOff } from 'lucide-react';
// Register all Handsontable's modules.
registerAllModules();
interface Task {
task: string;
assignee: string;
dueDate: string;
flagged: boolean;
}
const 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 },
];
// A renderer component receives the cell's value and coordinates, so the
// Lucide icon goes in as JSX. No `data-lucide` placeholder and no
// `createIcons()` scan - React renders the `<svg>` itself.
//
// Pass a renderer component through `<HotColumn renderer={...}>`, not through
// a `renderer` key in the `columns` array. Only the prop is wrapped for React;
// a component in `columns` reaches the grid as a plain renderer function and
// is called with `(instance, TD, row, ...)` instead of a props object.
const FlagCell = ({ instance, row, value }: HotRendererProps) => {
const taskName = String(instance.getDataAtCell(row, 0));
const flagged = Boolean(value);
// The icon has no accessible name, so the button carries an `aria-label`
// that describes what clicking it does.
const label = flagged
? `Remove the follow-up flag from ${taskName}`
: `Flag ${taskName} for follow-up`;
return (
<button
type="button"
aria-label={label}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.25rem' }}
onClick={() => instance.setDataAtCell(row, 3, !flagged)}
>
{/* `currentColor` is Lucide's default stroke, so the icon stays visible
in both light and dark themes. */}
{flagged ? <Flag size={18} /> : <FlagOff size={18} />}
</button>
);
};
const ExampleComponent = () => {
return (
<HotTable
data={data}
colHeaders={['Task', 'Assignee', 'Due date', 'Flagged']}
height="auto"
autoWrapRow={true}
autoWrapCol={true}
licenseKey="non-commercial-and-evaluation"
>
<HotColumn data="task" />
<HotColumn data="assignee" />
<HotColumn
data="dueDate"
type="date"
dateFormat={{ year: 'numeric', month: '2-digit', day: '2-digit' }}
/>
<HotColumn data="flagged" className="htCenter" renderer={FlagCell} />
</HotTable>
);
};
export default ExampleComponent;

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.