docs/content/recipes/accessibility/aria-grid/aria-grid.md
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.
::: only-for javascript
::: example #example1 :hot-recipe --js 1
@code
:::
:::
::: only-for react
::: example #example1 :react-advanced --js 1 --ts 2
:::
::: only-for angular
::: example #example1 :angular --ts 1 --html 2
:::
:::
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
A grid that:
role="grid", role="row", and role="gridcell" on the DOM elements screen readers expectaria-label on each cell combining the column name and the cell value (e.g., "Name: Ana García")aria-sort="none" initially, updated to ascending or descending when sortedNo 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.
ariaTagsconst 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.
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.
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.
aria-label to 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 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.
aria-sort on 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 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.
ariaTags: 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.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.To verify the ARIA attributes are present:
<td> inside the grid. The Accessibility panel shows the computed role (gridcell) and the aria-label value.<th> in the column header row and verify aria-sort is present.<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.
ariaTags: true adds the semantic roles (grid, row, gridcell) that screen readers rely on.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.aria-live regions outside the grid to announce data loading states when used with the DataProvider plugin.aria-label and role attributes address.