ARIA-friendly grid
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.
/* file: app.component.ts */import { Component } from '@angular/core';import { GridSettings, HotTableModule } from '@handsontable/angular-wrapper';import Handsontable from 'handsontable/base';import { getRenderer } from 'handsontable/renderers';
const colHeaders = ['Name', 'Department', 'Role', 'Salary', 'Start Date'];
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' },];
@Component({ standalone: true, imports: [HotTableModule], selector: 'example1-aria-grid', template: ` <hot-table [data]="data" [settings]="gridSettings"></hot-table> `,})export class AppComponent { readonly data = data;
readonly gridSettings: GridSettings = { colHeaders, rowHeaders: true, height: 'auto', width: '100%',
// Enable ARIA role attributes on grid, row, and cell DOM elements. ariaTags: true,
// Tab moves focus to the next row (same column) instead of the next cell. tabMoves: { row: 1, col: 0 },
// Enter confirms the edit and moves to the next row. enterMoves: { row: 1, col: 0 },
// Prevent wrap-around navigation, which disorients screen reader users. autoWrapRow: false, autoWrapCol: false,
// Enable column sorting so aria-sort on headers is meaningful. columnSorting: true,
columns: [ { data: 'name' }, { data: 'department' }, { data: 'role' }, { data: 'salary' }, { data: 'startDate' }, ],
// Custom renderer that sets aria-label to "Column Name: cell value" on every cell. cells() { return { renderer(hotInstance: Handsontable, TD: HTMLTableCellElement, row: number, col: number, prop: string | number, value: Handsontable.CellValue, cellProperties: Handsontable.CellProperties) { getRenderer('text')(hotInstance, TD, row, col, prop, value, cellProperties); TD.setAttribute('aria-label', `${colHeaders[col]}: ${value ?? 'empty'}`); }, }; },
afterGetColHeader(col: number, TH: HTMLTableCellElement) { // Set the initial aria-sort state. The columnSorting plugin updates this // attribute automatically when the user sorts a column. if (!TH.hasAttribute('aria-sort')) { TH.setAttribute('aria-sort', 'none'); } }, };}/* 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';
registerAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div><example1-aria-grid></example1-aria-grid></div>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", 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
- 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.
Enable ARIA roles with
ariaTagsconst hot = new Handsontable(container, {ariaTags: true,// ...});What’s happening: Setting
ariaTags: trueinstructs Handsontable to stamprole="grid"on the outermost table container,role="row"on each row element, androle="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.
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.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
autoWrapRowandautoWrapColtofalsemakes the grid stop at the boundaries, which matches what screen reader users expect from a navigation region.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.1 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. When the user clicks a header to sort, Handsontable’scolumnSortingplugin updatesaria-sortautomatically 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
- Initial render —
ariaTags: truestampsrole="grid",role="row", androle="gridcell"on the DOM. TheafterGetColHeaderhook setsaria-sort="none"on each header. 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: trueadds 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. afterGetColHeaderinitializesaria-sort="none"on each header; thecolumnSortingplugin updates it automatically when sorting.tabMovesandenterMovesset to{ row: 1, col: 0 }align keyboard navigation with screen reader conventions.autoWrapRow: falseandautoWrapCol: falseprevent disorienting focus jumps at grid boundaries.
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 — 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-labelandroleattributes address.