Skip to content

In this tutorial, you will configure Handsontable for screen reader compatibility. You will learn how to use ariaTags, tabMoves, aria-label on cells, and aria-sort on headers to target WCAG 2.1 AA compliance.

JavaScript
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import { getRenderer } from 'handsontable/renderers';
registerAllModules();
/* start:skip-in-preview */
const data = [
{ name: 'Ana García', department: 'Engineering', role: 'Senior Engineer', salary: 95000, startDate: '2019-03-12' },
{ name: 'James Okafor', department: 'Product', role: 'Product Manager', salary: 105000, startDate: '2020-07-01' },
{ name: 'Li Wei', department: 'Design', role: 'UX Designer', salary: 88000, startDate: '2021-01-15' },
{ name: 'Priya Sharma', department: 'Engineering', role: 'Tech Lead', salary: 120000, startDate: '2018-09-05' },
{ name: 'Carlos Mendez', department: 'HR', role: 'HR Specialist', salary: 72000, startDate: '2022-02-20' },
{ name: 'Sarah Chen', department: 'Finance', role: 'Financial Analyst', salary: 91000, startDate: '2020-11-30' },
{ name: 'Omar Hassan', department: 'Engineering', role: 'Backend Engineer', salary: 98000, startDate: '2021-06-14' },
{ name: 'Emma Wilson', department: 'Marketing', role: 'Marketing Lead', salary: 85000, startDate: '2019-08-22' },
];
/* end:skip-in-preview */
const colHeaders = ['Name', 'Department', 'Role', 'Salary', 'Start Date'];
const ExampleComponent = () => {
return (
<HotTable
data={data}
colHeaders={colHeaders}
rowHeaders={true}
height="auto"
width="100%"
ariaTags={true}
tabMoves={{ row: 1, col: 0 }}
enterMoves={{ row: 1, col: 0 }}
autoWrapRow={false}
autoWrapCol={false}
columnSorting={true}
columns={[
{ data: 'name' },
{ data: 'department' },
{ data: 'role' },
{ data: 'salary' },
{ data: 'startDate' },
]}
cells={function() {
return {
renderer(hotInstance, TD, row, col, prop, value, cellProperties) {
getRenderer('text')(hotInstance, TD, row, col, prop, value, cellProperties);
TD.setAttribute('aria-label', `${colHeaders[col]}: ${value ?? 'empty'}`);
},
};
}}
afterGetColHeader={function(col, TH) {
if (!TH.hasAttribute('aria-sort')) {
TH.setAttribute('aria-sort', 'none');
}
}}
licenseKey="non-commercial-and-evaluation"
/>
);
};
export default ExampleComponent;
TypeScript
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import { getRenderer } from 'handsontable/renderers';
import type Handsontable from 'handsontable/base';
registerAllModules();
/* start:skip-in-preview */
type EmployeeRow = {
name: string;
department: string;
role: string;
salary: number;
startDate: string;
};
const data: EmployeeRow[] = [
{ name: 'Ana García', department: 'Engineering', role: 'Senior Engineer', salary: 95000, startDate: '2019-03-12' },
{ name: 'James Okafor', department: 'Product', role: 'Product Manager', salary: 105000, startDate: '2020-07-01' },
{ name: 'Li Wei', department: 'Design', role: 'UX Designer', salary: 88000, startDate: '2021-01-15' },
{ name: 'Priya Sharma', department: 'Engineering', role: 'Tech Lead', salary: 120000, startDate: '2018-09-05' },
{ name: 'Carlos Mendez', department: 'HR', role: 'HR Specialist', salary: 72000, startDate: '2022-02-20' },
{ name: 'Sarah Chen', department: 'Finance', role: 'Financial Analyst', salary: 91000, startDate: '2020-11-30' },
{ name: 'Omar Hassan', department: 'Engineering', role: 'Backend Engineer', salary: 98000, startDate: '2021-06-14' },
{ name: 'Emma Wilson', department: 'Marketing', role: 'Marketing Lead', salary: 85000, startDate: '2019-08-22' },
];
/* end:skip-in-preview */
const colHeaders: string[] = ['Name', 'Department', 'Role', 'Salary', 'Start Date'];
const ExampleComponent = () => {
return (
<HotTable
data={data}
colHeaders={colHeaders}
rowHeaders={true}
height="auto"
width="100%"
ariaTags={true}
tabMoves={{ row: 1, col: 0 }}
enterMoves={{ row: 1, col: 0 }}
autoWrapRow={false}
autoWrapCol={false}
columnSorting={true}
columns={[
{ data: 'name' },
{ data: 'department' },
{ data: 'role' },
{ data: 'salary' },
{ data: 'startDate' },
]}
cells={function(): Handsontable.CellMeta {
return {
renderer(
hotInstance: Handsontable,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: string | number,
value: Handsontable.CellValue,
cellProperties: Handsontable.CellProperties,
): void {
getRenderer('text')(hotInstance, TD, row, col, prop, value, cellProperties);
TD.setAttribute('aria-label', `${colHeaders[col]}: ${value ?? 'empty'}`);
},
};
}}
afterGetColHeader={function(col: number, TH: HTMLTableCellElement): void {
if (!TH.hasAttribute('aria-sort')) {
TH.setAttribute('aria-sort', 'none');
}
}}
licenseKey="non-commercial-and-evaluation"
/>
);
};
export default ExampleComponent;

Overview

This recipe shows how to configure Handsontable so screen readers can navigate the grid meaningfully. You will enable ARIA roles on grid elements, add descriptive aria-label attributes to every cell, expose sort state on column headers, and configure keyboard navigation to match screen reader conventions.

Difficulty: Beginner - Intermediate Time: ~15 minutes

What You’ll Build

A grid that:

  • Exposes role="grid", role="row", and role="gridcell" on the DOM elements screen readers expect
  • Sets aria-label on each cell combining the column name and the cell value (e.g., "Name: Ana García")
  • Marks each column header with aria-sort="none" initially, updated to ascending or descending when sorted
  • Moves focus one row down on both Tab and Enter so keyboard users advance predictably
  • Disables wrap-around navigation to prevent disorienting jumps to the opposite end of the grid

Before you begin

No additional dependencies are required. This recipe uses only the Handsontable core library.

If you have not set up a Handsontable project yet, follow the Quick start guide first.

  1. Enable ARIA roles with ariaTags

    const hot = new Handsontable(container, {
    ariaTags: true,
    // ...
    });

    What’s happening: Setting ariaTags: true instructs Handsontable to stamp role="grid" on the outermost table container, role="row" on each row element, and role="gridcell" on each data cell. These roles are required by ARIA’s grid pattern, allowing screen readers to announce the structure of the table correctly.

    Without this option the DOM is still a visual table, but screen readers have no semantic cues to describe rows and cells as belonging to an interactive data grid.

  2. Configure Tab and Enter navigation for screen readers

    const hot = new Handsontable(container, {
    tabMoves: { row: 1, col: 0 },
    enterMoves: { row: 1, col: 0 },
    // ...
    });

    What’s happening:

    • tabMoves: { row: 1, col: 0 } makes Tab move to the next row in the same column instead of the next column. Screen reader users typically use Tab to move between interactive regions, and moving row-by-row matches that mental model better than moving cell-by-cell.
    • enterMoves: { row: 1, col: 0 } makes Enter commit a cell edit and advance one row down. This mirrors spreadsheet conventions that screen reader users already know.

    Both options accept a { row, col } object. Negative values move backwards.

  3. Disable wrap-around navigation

    const hot = new Handsontable(container, {
    autoWrapRow: false,
    autoWrapCol: false,
    // ...
    });

    What’s happening: By default, pressing Tab on the last column wraps focus to the first column of the next row, and pressing Tab on the last cell of the grid wraps back to the first cell. For sighted users this is convenient. For screen reader users it is disorienting — the reader may announce a sudden column or row change that appears to have no cause.

    Setting both autoWrapRow and autoWrapCol to false makes the grid stop at the boundaries, which matches what screen reader users expect from a navigation region.

  4. Add aria-label to cells with a custom renderer

    const colHeaders = ['Name', 'Department', 'Role', 'Salary', 'Start Date'];
    const hot = new Handsontable(container, {
    colHeaders,
    cells() {
    return {
    renderer(hotInstance, TD, row, col, prop, value, cellProperties) {
    getRenderer('text')(hotInstance, TD, row, col, prop, value, cellProperties);
    TD.setAttribute('aria-label', `${colHeaders[col]}: ${value || 'empty'}`);
    },
    };
    },
    // ...
    });

    What’s happening: The cells() callback returns a renderer for every cell. Inside the renderer:

    1. getRenderer('text')(...) runs the built-in text renderer first. This ensures default rendering behavior (escaping, class names) is preserved.
    2. TD.setAttribute('aria-label', ...) adds a human-readable label. The format "Column: value" gives screen readers a concise, self-contained description of each cell, for example "Salary: 95000" instead of just "95000".

    Passing value || 'empty' ensures that blank cells are announced as "Name: empty" rather than "Name: ", which some screen readers skip entirely.

    Why use cells() instead of columns? cells() applies the renderer to every column in one place. If you need column-specific formatting alongside the aria-label, move the renderer into each entry in the columns array instead.

  5. Set aria-sort on column headers

    const hot = new Handsontable(container, {
    columnSorting: true,
    afterGetColHeader(col, TH) {
    if (!TH.hasAttribute('aria-sort')) {
    TH.setAttribute('aria-sort', 'none');
    }
    },
    // ...
    });

    What’s happening: The afterGetColHeader hook fires every time Handsontable renders a column header. The callback receives the visual column index (col) and the <th> DOM element (TH).

    The aria-sort attribute on a column header tells screen readers whether the column is sorted and in which direction. WCAG 2.1 Success Criterion 1.3.1 requires that sort state is conveyed programmatically, not just visually.

    The check !TH.hasAttribute('aria-sort') avoids overwriting aria-sort when the columnSorting plugin has already set it. When the user clicks a header to sort, Handsontable’s columnSorting plugin updates aria-sort automatically to "ascending" or "descending". The hook above only sets the initial "none" state that the plugin does not set on first render.

How It Works - Complete Flow

  1. Initial renderariaTags: true stamps role="grid", role="row", and role="gridcell" on the DOM. The afterGetColHeader hook sets aria-sort="none" on each header. The custom renderer sets aria-label="Column: value" on each cell.
  2. User presses Tab — Focus moves to the next row in the same column (not the next cell sideways). Screen readers announce the new row.
  3. User presses Enter — Any open editor commits the value and focus moves one row down.
  4. User clicks a column header — The columnSorting plugin sorts the data and updates aria-sort on the header to "ascending" or "descending". The custom renderer re-runs and updates all aria-label attributes to reflect the new row order.
  5. User reaches the grid boundary — Tab and Enter stop. No unexpected wrap-around jump occurs.

Testing with Chrome DevTools Accessibility panel

To verify the ARIA attributes are present:

  1. Open the page in Chrome and right-click the grid.
  2. Select Inspect.
  3. In the Elements panel, click the Accessibility tab (top right area of DevTools).
  4. Select any <td> inside the grid. The Accessibility panel shows the computed role (gridcell) and the aria-label value.
  5. Select a <th> in the column header row and verify aria-sort is present.
  6. Click a column header to sort, then re-select the <th>. The aria-sort attribute should update to ascending or descending.

You can also use the Full Page Accessibility Tree (the document icon in the Accessibility panel) to browse all roles and labels without needing to click individual elements.

What you learned

  • ariaTags: true adds the semantic roles (grid, row, gridcell) that screen readers rely on.
  • A custom renderer calling TD.setAttribute('aria-label', ...) gives every cell a descriptive, self-contained label.
  • afterGetColHeader initializes aria-sort="none" on each header; the columnSorting plugin updates it automatically when sorting.
  • tabMoves and enterMoves set to { row: 1, col: 0 } align keyboard navigation with screen reader conventions.
  • autoWrapRow: false and autoWrapCol: false prevent disorienting focus jumps at grid boundaries.

Next steps

  • Explore the full ARIA Grid pattern in the WAI-ARIA Authoring Practices.
  • Add aria-live regions outside the grid to announce data loading states when used with the DataProvider plugin.
  • Test with a real screen reader — NVDA (Windows, free) and VoiceOver (macOS/iOS, built-in) are the most commonly used.
  • Review WCAG 2.1 Success Criterion 4.1.2 for the full Name, Role, Value requirement that aria-label and role attributes address.