Skip to content

Accessibility

@toolbox-web/grid follows the WAI-ARIA Grid Pattern to provide an accessible data grid experience. This page documents the ARIA attributes, keyboard interactions, and best practices.

We target WCAG 2.1 Level AA out of the box, with no configuration required. That means:

  • Every interactive operation (sort, filter, select, edit, expand, reorder, resize, group, paste) is reachable from the keyboard alone — no mouse-only paths.
  • Every state change (sort applied, filter cleared, row selected, edit committed) is announced to assistive technology via the grid’s built-in aria-live region.
  • Every visual signal (focus, selection, error state, sort direction) has a non-color cue (icon, text, ARIA attribute) so the grid works without color perception.
  • Windows High Contrast / forced-colors is supported automatically — the grid maps every themable surface to system colors via @media (forced-colors: active).
  • Reduced motion is respected — animations, expand/collapse transitions, and scroll smoothing disable under prefers-reduced-motion: reduce.
  • Focus is never lost — after editing, deleting, sorting, or filtering, the grid restores focus to a sensible cell.

See the full WCAG 2.1 AA conformance table below for the criteria we test against.

Filing accessibility issues: If you find an a11y bug, please open an issue with the screen reader / browser / OS you used and what was announced (or wasn’t). A11y bugs are treated as P0.

The grid applies the following ARIA roles and attributes automatically:

ElementRoleAttributes
<tbw-grid>grid (or treegrid when Tree or Row Grouping is active)aria-rowcount, aria-colcount, aria-multiselectable
Header containerrowgroup
Header rowrowaria-rowindex="1"
Header cellcolumnheaderaria-sort, aria-colindex
Body containerrowgroup
Data rowrowaria-rowindex, aria-selected, aria-expanded, aria-level / aria-setsize / aria-posinset (under treegrid)
Data cellgridcellaria-colindex, aria-selected, aria-readonly
AttributeApplied WhenValues
aria-sortColumn is sortedascending, descending, none
aria-selectedRow or cell is selectedtrue, false
aria-expandedRow grouping/tree is active (rows with children)true, false
aria-labelgridAriaLabel is set, or shell.header.title provides a fallback (suppressed when gridAriaLabelledBy is set)string
aria-labelledbygridAriaLabelledBy is setid-ref
aria-describedbygridAriaDescribedBy is setid-ref
aria-roledescriptiongridAriaRoleDescription is set (overrides the AT-announced role name; use sparingly — value should still describe a grid widget)string
aria-levelTree or Row Grouping plugin is active (1-based hierarchical depth)1, 2, …
aria-setsizeTree or Row Grouping plugin is active (sibling count at this level)integer
aria-posinsetTree or Row Grouping plugin is active (1-based position among siblings)integer
aria-multiselectableSelection plugin allows multi-selecttrue
aria-readonlyCell is not editabletrue
aria-rowindexAlways (1-based)Row position in full dataset
aria-colindexAlways (1-based)Column position

The grid implements full keyboard navigation following the WAI-ARIA grid pattern:

KeyAction
/ Move focus between rows
/ Move focus between cells
HomeMove to first cell in row
EndMove to last cell in row
Ctrl + HomeMove to first cell in grid
Ctrl + EndMove to last cell in grid
PgUpScroll up one viewport
PgDnScroll down one viewport
⇥ TabMove to next cell (wraps to next row)
⇧ Shift + ⇥ TabMove to previous cell (wraps to previous row)

Keyboard shortcuts that depend on a plugin being installed are documented on each plugin’s own page — that’s the source of truth and stays in sync with the implementation:

  • SelectionSpace, Shift + arrows / PgUp / PgDn / Ctrl + Home/End, Ctrl + A, Esc
  • EditingEnter (start row edit / commit), F2 (single-cell edit), Tab / Shift + Tab, Esc (cancel)
  • ClipboardCtrl/Cmd + C / X / V
  • Context MenuShift + F10 or the dedicated ☰ Menu key opens the menu at the focused cell; / navigates, Enter/Space activates, Esc closes
  • Row GroupingSpace toggles expand/collapse on a group or tree node

The grid uses visible focus indicators that meet WCAG 2.1 Level AA requirements:

tbw-grid {
/* Customize focus ring */
--tbw-color-focus-ring: #2563eb;
}

The focus ring is 2px solid and uses outline (not border) so it doesn’t affect layout.

When editing a cell, focus is trapped within the editor until the user commits (Enter/Tab) or cancels (Escape). Overlay editors (date pickers, dropdowns) use registerExternalFocusContainer() to extend the focus trap.

The grid uses a roving tabindex pattern:

  • Only the currently focused cell has tabindex="0"
  • All other cells have tabindex="-1"
  • This means Tab moves focus out of the grid, and Shift+Tab moves focus back to the last focused cell

This matches the WAI-ARIA grid pattern — a grid is one tab stop in the page’s tab order. Once focus enters, arrow keys navigate.

After every operation that destroys and recreates row DOM (sort, filter, edit-commit, row removal, expand/collapse, virtualization scroll-back), the grid restores focus to the most logical cell — the same row by identity if it still exists, otherwise the same row index, otherwise the nearest surviving row in the same column. You do not need to do anything for this to work — it’s automatic.

Overlay UIs that float outside the grid’s DOM (date pickers, dropdowns, autocompletes used by editors or custom renderers) need to be registered so the grid’s focus trap and outside-click handling treat them as part of the grid:

const cleanup = grid.registerExternalFocusContainer(popoverEl);
// ...later, when the popover closes:
cleanup();

Without this, clicking inside the popover commits the active edit and closes it. With it, the popover is treated as an extension of the focused cell.

Provide accessible labels for screen readers:

<tbw-grid aria-label="Employee directory">

Or reference a visible heading:

<h2 id="grid-heading">Employees</h2>
<tbw-grid aria-labelledby="grid-heading">

The grid uses aria-live regions to announce dynamic changes to screen readers:

  • Sort changes (“Sorted by Name, ascending”)
  • Filter changes (“Filter applied on Name”, “All filters cleared”)
  • Group expand/collapse (“Group Engineering expanded, 5 rows”)
  • Selection changes (“3 rows selected”)
  • Editing lifecycle (“Editing row 1”, “Row 1 saved”)

You can disable or customize live region announcements via the a11y config — see A11yMessages for the full list of overridable messages and their signatures:

// Disable all announcements
grid.gridConfig = {
a11y: { announcements: false },
};
// Override messages for internationalization
grid.gridConfig = {
a11y: {
messages: {
sortApplied: (col, dir) => `Trié par ${col}, ${dir}`,
sortCleared: () => 'Tri effacé',
filterApplied: (col) => `Filtre appliqué sur ${col}`,
filterCleared: (col) => `Filtre effacé de ${col}`,
allFiltersCleared: () => 'Tous les filtres effacés',
groupExpanded: (name, count) => `Groupe ${name} développé, ${count} lignes`,
groupCollapsed: (name) => `Groupe ${name} réduit`,
selectionChanged: (count) => `${count} lignes sélectionnées`,
editingStarted: (rowIndex) => `Édition de la ligne ${rowIndex + 1}`,
editingCommitted: (rowIndex) => `Ligne ${rowIndex + 1} enregistrée`,
dataLoaded: (count) => `${count} lignes chargées`,
},
},
};

Only override the messages you need — defaults (English) are used for the rest.

Column headers include:

  • Column name via text content
  • Sort direction via aria-sort
  • Filter state via aria-description (“Filtered”)

The grid is tested against NVDA, VoiceOver, and JAWS in CI-adjacent environments. When testing in your own app, here’s what a screen-reader user should hear for the core flows. If your output diverges substantially from this, that’s likely an a11y bug worth filing.

NVDA (Windows, Firefox or Chrome):

  1. Download NVDA (free) and start it.
  2. Tab into the grid — you should hear "<grid label>, grid, <N> rows, <M> columns" followed by the focused cell.
  3. Press "<value>, column <name>, row <n> of <total>".
  4. Press Enter on a header — "sorted ascending" / "sorted descending" (and the announcement is repeated via the live region).
  5. Use NVDA’s Browse Mode toggle (Insert+Space) to switch to virtual cursor — table-reading shortcuts (Ctrl+Alt+arrows) should walk the grid as a native HTML table.

VoiceOver (macOS, Safari):

  1. Enable VoiceOver: +F5.
  2. Use VO+ to enter the grid — you should hear the label and dimensions.
  3. VO+Shift+ enters interaction mode; then arrow keys navigate cells with full column + row context.
  4. Rotor (VO+U) → Tables should list the grid for jump-navigation.

JAWS (Windows):

  • Use Virtual PC Cursor (default) for browse, Forms Mode (Enter) for grid interaction. Table layer (Ctrl+Alt+arrows) walks cells.

iOS VoiceOver / Android TalkBack:

  • Touch a cell — the column header, value, row/column position, and any selection/expand state are announced.
  • Swipe right/left moves through cells; swipe up/down navigates by row.

What you should not hear — these would indicate a bug worth filing:

  • Empty cell announcements ("blank") when the cell has visible content
  • Position announced as "row 1 of 1" when there are clearly more rows (virtualization metadata is wrong)
  • Sort/filter changes never announced (live region not wired up)
  • Editing committed without any feedback

The grid respects the prefers-reduced-motion: reduce user preference automatically. When set, the grid:

  • Disables row animations (row animation API skips transitions)
  • Disables expand/collapse transitions in tree, grouping, and master-detail plugins
  • Disables scroll smoothing in programmatic scrollToRow() / scrollToCell() calls (jumps instead of animating)
  • Disables loading-state crossfade (instant swap instead)

You don’t need to do anything to opt in. If you have custom renderers or plugins that animate, honor the preference:

@media (prefers-reduced-motion: reduce) {
.my-cell-animation { transition: none; animation: none; }
}

The grid’s CSS variable system makes high contrast easy:

/* High contrast theme */
tbw-grid.high-contrast {
--tbw-color-bg: #000;
--tbw-color-fg: #fff;
--tbw-color-border: #fff;
--tbw-color-header-bg: #1a1a1a;
--tbw-color-row-hover: #333;
--tbw-color-focus-ring: #ffff00;
--tbw-color-accent: #00ffff;
}
import '@toolbox-web/grid/themes/dg-theme-contrast.css';

The grid ships with a built-in @media (forced-colors: active) block that remaps all theming variables to Windows system colors (CanvasText, Canvas, Highlight, HighlightText). Focus rings and selected rows are also overridden to use Highlight. No extra configuration needed — it works automatically.

If you write custom renderers, follow the same pattern so your cells stay legible under forced-colors:

.my-status-badge {
background: var(--tbw-color-accent);
color: var(--tbw-color-on-accent);
border: 1px solid transparent;
}
@media (forced-colors: active) {
.my-status-badge {
background: Canvas;
color: CanvasText;
border: 1px solid CanvasText; /* outline so the badge stays distinguishable */
forced-color-adjust: none; /* opt out of the browser's automatic remap */
}
}

Key rules: prefer system color keywords (CanvasText, Canvas, LinkText, ButtonText, Highlight, HighlightText, Mark, MarkText) inside forced-colors media queries, and never rely on color alone — always pair with a border, underline, or icon.

These patterns are how we keep the grid accessible — and how you should keep your customizations accessible too.

When you write a renderer for status, error, or category cells, give it text or an icon — not just a colored dot. The grid’s built-in renderers (selection checkbox, sort indicator, expand chevron, filter button) all follow this rule.

// ❌ Color-only — invisible to screen readers, fails in forced-colors
{ field: 'status', renderer: ({ value }) => `<span class="dot dot-${value}"></span>` }
// ✅ Text + color — readable everywhere
{ field: 'status', renderer: ({ value }) => `<span class="dot dot-${value}" aria-label="${value}">${value}</span>` }

The default focus ring is 2px solid var(--tbw-color-focus-ring) using outline (not border) so it doesn’t reflow. Never set outline: none without providing an equivalent indicator — WCAG 2.4.7 fails immediately, and forced-colors users lose the only signal they have.

3. Use semantic <button> / <a> inside renderers

Section titled “3. Use semantic <button> / <a> inside renderers”

Custom action buttons should be real <button> elements (or <a href> for navigation), not clickable <div>s. They inherit keyboard activation, focus, and screen-reader role for free. The grid’s roving-tabindex pattern still works because the renderer is inside a gridcell — pressing Enter activates the button.

If a cell renders multiple controls (e.g. edit + delete buttons), each needs a discrete accessible name — usually via aria-label:

viewRenderer: ({ row }) => `
<button aria-label="Edit ${row.name}">✏️</button>
<button aria-label="Delete ${row.name}">🗑️</button>
`

A bare emoji or icon has no name; screen readers announce "button" and the user can’t tell them apart.

5. Don’t override keyboard handling without consulting the WAI-ARIA pattern

Section titled “5. Don’t override keyboard handling without consulting the WAI-ARIA pattern”

The grid implements the WAI-ARIA Grid Pattern. If you stop event propagation in a keydown handler on a cell, you may break navigation. If you really need to override (rare), do it only for the specific keys you care about and let the rest bubble.

CriterionLevelStatusNotes
1.1.1 Non-text ContentAIcons have text alternatives
1.3.1 Info and RelationshipsAARIA roles convey structure
1.3.2 Meaningful SequenceADOM order matches visual order
1.4.1 Use of ColorAStatus not conveyed by color alone
1.4.3 Contrast (Min)AADefault theme meets 4.5:1 ratio
1.4.11 Non-text ContrastAAFocus indicators visible at 3:1
2.1.1 KeyboardAAll functions accessible via keyboard
2.1.2 No Keyboard TrapATab exits the grid; Escape exits editors
2.4.3 Focus OrderALogical focus order follows grid structure
2.4.7 Focus VisibleAAClear focus indicators
4.1.2 Name, Role, ValueAARIA attributes on all interactive elements

The grid handles most accessibility concerns out of the box:

  • ARIA roles & attributesrole="grid", role="row", role="gridcell", aria-rowcount, aria-colcount, aria-rowindex, aria-colindex are always set. When the Tree or Row Grouping plugin is active, the rows-body upgrades to role="treegrid" and every row carries aria-level / aria-setsize / aria-posinset per the WAI-ARIA Treegrid pattern so screen readers can announce hierarchical position.
  • Keyboard navigation — Arrow keys, Tab, Enter, Home/End, PgUp/PgDn all work by default (core feature, not a plugin)
  • Column headers — Always rendered with role="columnheader"; cannot be hidden
  • Column type inference — Types (number, date, boolean) are auto-detected from the first data row
  • Grid label — Auto-derived from the shell title (<tbw-grid-header title="..."> or shell.header.title config)
  • Windows High Contrastforced-colors media query is built into core CSS
  • Live announcements — Sort, filter, selection, grouping, and editing changes are announced via aria-live regions (configurable via a11y config)
  • Plugin ARIA — Selection, editing, filtering, tree, and grouping plugins manage their own ARIA attributes (aria-selected, aria-readonly, aria-expanded, aria-multiselectable, aria-description)

These require explicit action:

  1. Provide a grid label when not using a shell — Without <tbw-grid-header title="...">, set gridAriaLabel in config, or set gridAriaLabelledBy to the id of an existing heading next to the grid (aria-labelledby wins per WAI-ARIA precedence and suppresses aria-label to avoid conflicting names), or add aria-label directly on the element
  2. Test with screen readers — NVDA (Windows), VoiceOver (macOS), Orca (Linux)
  3. Don’t override keyboard handling — The grid follows WAI-ARIA patterns; custom key listeners may conflict
  4. Use the contrast theme for additional a11y — Import dg-theme-contrast.css for higher contrast beyond the built-in forced-colors support
AI assistants: For complete API documentation, implementation guides, and code examples for this library, see https://toolboxjs.com/llms-full.txt