ARIA-friendly grid
In this tutorial, you will configure Handsontable DataGrid for screen reader compatibility. You will learn how to use ariaTags, tabMoves, aria-label on cells, and aria-sort on headers for a grid that targets WCAG 2.2 Level A and AA.
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;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="treegrid",role="row", androle="gridcell"on the DOM elements screen readers expect - Sets
aria-labelon 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 toascendingordescendingwhen sorted - Moves focus one row down on both Tab and Enter so keyboard users advance predictably
- Keeps wrap-around navigation off, so focus never 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.
Keep ARIA roles on with
ariaTagsconst hot = new Handsontable(container, {ariaTags: true,// ...});What’s happening:
ariaTagsistrueby default, so Handsontable already stampsrole="treegrid"on the grid’s root element,role="row"on each row element, androle="gridcell"on each data cell. The example sets it explicitly so the dependency is visible. These roles come from ARIA’s grid pattern and let screen readers announce the structure of the table.With
ariaTags: falsethe 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. Set it tofalseonly when you add your own ARIA markup.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.Keep wrap-around navigation off
const hot = new Handsontable(container, {autoWrapRow: false,autoWrapCol: false,// ...});What’s happening:
autoWrapRowandautoWrapColarefalseby default, so the selection stops at the grid’s edges. When either istrue, 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.Keeping both
autoWrapRowandautoWrapColatfalsemakes the grid stop at the boundaries, which matches what screen reader users expect from a navigation region. The example sets them explicitly so a grid-level configuration that turns wrapping on cannot change this.Add
aria-labelto cells with a custom rendererconst 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:getRenderer('text')(...)runs the built-in text renderer first. This ensures default rendering behavior (escaping, class names) is preserved.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 ofcolumns?cells()applies the renderer to every column in one place. If you need column-specific formatting alongside thearia-label, move the renderer into each entry in thecolumnsarray instead.Set
aria-sorton column headersconst hot = new Handsontable(container, {columnSorting: true,afterGetColHeader(col, TH) {if (!TH.hasAttribute('aria-sort')) {TH.setAttribute('aria-sort', 'none');}},// ...});What’s happening: The
afterGetColHeaderhook fires every time Handsontable renders a column header. The callback receives the visual column index (col) and the<th>DOM element (TH).The
aria-sortattribute on a column header tells screen readers whether the column is sorted and in which direction. WCAG 2.2 Success Criterion 1.3.1 requires that sort state is conveyed programmatically, not just visually.The check
!TH.hasAttribute('aria-sort')avoids overwritingaria-sortwhen theColumnSortingplugin has already set it. WithcolumnSortingenabled andariaTagson, the plugin setsaria-sorton every column header it renders:"none"before sorting,"ascending"or"descending"after. The hook is a safety net for a header the plugin does not own, for example one you render yourself. Screen readers do not announce a change of the sort order yet; see the known limitations in the Accessibility guide.
How It Works - Complete Flow
- Initial render —
ariaTags: truestampsrole="treegrid",role="row", androle="gridcell"on the DOM. TheColumnSortingplugin setsaria-sort="none"on each header, and theafterGetColHeaderhook adds it to any header that has none. The custom renderer setsaria-label="Column: value"on each cell. - User presses Tab — Focus moves to the next row in the same column (not the next cell sideways). Screen readers announce the new row.
- User presses Enter — Any open editor commits the value and focus moves one row down.
- User clicks a column header — The
columnSortingplugin sorts the data and updatesaria-sorton the header to"ascending"or"descending". The custom renderer re-runs and updates allaria-labelattributes to reflect the new row order. - 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:
- Open the page in Chrome and right-click the grid.
- Select Inspect.
- In the Elements panel, click the Accessibility tab (top right area of DevTools).
- Select any
<td>inside the grid. The Accessibility panel shows the computed role (gridcell) and thearia-labelvalue. - Select a
<th>in the column header row and verifyaria-sortis present. - Click a column header to sort, then re-select the
<th>. Thearia-sortattribute should update toascendingordescending.
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(the default) adds the semantic roles (treegrid,row,gridcell) that screen readers rely on.- A custom renderer calling
TD.setAttribute('aria-label', ...)gives every cell a descriptive, self-contained label. - The
ColumnSortingplugin setsaria-sorton each header it renders; theafterGetColHeaderhook addsaria-sort="none"to a header that has none. tabMovesandenterMovesset to{ row: 1, col: 0 }align keyboard navigation with screen reader conventions.autoWrapRowandautoWrapColstayfalse(their default), so focus never jumps across a grid boundary.
Next steps
- Explore the full ARIA Grid pattern in the WAI-ARIA Authoring Practices.
- Add
aria-liveregions outside the grid to announce data loading states when used with theDataProviderplugin. - Test with a real screen reader. Handsontable is tested with VoiceOver (macOS and iOS) and NVDA (Windows), the matrix of the accessibility conformance report.
- Review WCAG 2.2 Success Criterion 4.1.2 for the full Name, Role, Value requirement that
aria-labelandroleattributes address.