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.
Our accessibility commitment
Section titled “Our accessibility commitment”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-liveregion. - 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.
ARIA Roles & Attributes
Section titled “ARIA Roles & Attributes”The grid applies the following ARIA roles and attributes automatically:
Grid Structure
Section titled “Grid Structure”| Element | Role | Attributes |
|---|---|---|
<tbw-grid> | grid (or treegrid when Tree or Row Grouping is active) | aria-rowcount, aria-colcount, aria-multiselectable |
| Header container | rowgroup | — |
| Header row | row | aria-rowindex="1" |
| Header cell | columnheader | aria-sort, aria-colindex |
| Body container | rowgroup | — |
| Data row | row | aria-rowindex, aria-selected, aria-expanded, aria-level / aria-setsize / aria-posinset (under treegrid) |
| Data cell | gridcell | aria-colindex, aria-selected, aria-readonly |
Dynamic Attributes
Section titled “Dynamic Attributes”| Attribute | Applied When | Values |
|---|---|---|
aria-sort | Column is sorted | ascending, descending, none |
aria-selected | Row or cell is selected | true, false |
aria-expanded | Row grouping/tree is active (rows with children) | true, false |
aria-label | gridAriaLabel is set, or shell.header.title provides a fallback (suppressed when gridAriaLabelledBy is set) | string |
aria-labelledby | gridAriaLabelledBy is set | id-ref |
aria-describedby | gridAriaDescribedBy is set | id-ref |
aria-roledescription | gridAriaRoleDescription is set (overrides the AT-announced role name; use sparingly — value should still describe a grid widget) | string |
aria-level | Tree or Row Grouping plugin is active (1-based hierarchical depth) | 1, 2, … |
aria-setsize | Tree or Row Grouping plugin is active (sibling count at this level) | integer |
aria-posinset | Tree or Row Grouping plugin is active (1-based position among siblings) | integer |
aria-multiselectable | Selection plugin allows multi-select | true |
aria-readonly | Cell is not editable | true |
aria-rowindex | Always (1-based) | Row position in full dataset |
aria-colindex | Always (1-based) | Column position |
Keyboard Navigation
Section titled “Keyboard Navigation”The grid implements full keyboard navigation following the WAI-ARIA grid pattern:
Basic Navigation
Section titled “Basic Navigation”| Key | Action |
|---|---|
| ↑ / ↓ | Move focus between rows |
| ← / → | Move focus between cells |
| Home | Move to first cell in row |
| End | Move to last cell in row |
| Ctrl + Home | Move to first cell in grid |
| Ctrl + End | Move to last cell in grid |
| PgUp | Scroll up one viewport |
| PgDn | Scroll down one viewport |
| ⇥ Tab | Move to next cell (wraps to next row) |
| ⇧ Shift + ⇥ Tab | Move to previous cell (wraps to previous row) |
Plugin-Specific Shortcuts
Section titled “Plugin-Specific Shortcuts”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:
- Selection — Space, Shift + arrows / PgUp / PgDn / Ctrl + Home/End, Ctrl + A, Esc
- Editing — Enter (start row edit / commit), F2 (single-cell edit), Tab / Shift + Tab, Esc (cancel)
- Clipboard — Ctrl/Cmd + C / X / V
- Context Menu — Shift + F10 or the dedicated ☰ Menu key opens the menu at the focused cell; ↑/↓ navigates, Enter/Space activates, Esc closes
- Row Grouping — Space toggles expand/collapse on a group or tree node
Focus Management
Section titled “Focus Management”Focus Indicators
Section titled “Focus Indicators”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.
Focus Trapping
Section titled “Focus Trapping”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.
Roving Tabindex
Section titled “Roving Tabindex”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.
Focus restoration
Section titled “Focus restoration”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.
External focus containers
Section titled “External focus containers”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.
Screen Reader Support
Section titled “Screen Reader Support”Labels and Descriptions
Section titled “Labels and Descriptions”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">Live Regions
Section titled “Live Regions”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”)
Configuring Announcements
Section titled “Configuring Announcements”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 announcementsgrid.gridConfig = { a11y: { announcements: false },};
// Override messages for internationalizationgrid.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
Section titled “Column Headers”Column headers include:
- Column name via text content
- Sort direction via
aria-sort - Filter state via
aria-description(“Filtered”)
Testing with screen readers
Section titled “Testing with screen readers”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):
- Download NVDA (free) and start it.
- Tab into the grid — you should hear
"<grid label>, grid, <N> rows, <M> columns"followed by the focused cell. - Press ↓ —
"<value>, column <name>, row <n> of <total>". - Press Enter on a header —
"sorted ascending"/"sorted descending"(and the announcement is repeated via the live region). - 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):
- Enable VoiceOver: ⌘+F5.
- Use VO+→ to enter the grid — you should hear the label and dimensions.
- VO+Shift+↓ enters interaction mode; then arrow keys navigate cells with full column + row context.
- 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
Reduced Motion
Section titled “Reduced Motion”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; }}High Contrast Mode
Section titled “High Contrast Mode”Using CSS Custom Properties
Section titled “Using CSS Custom Properties”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;}Pre-built Contrast Theme
Section titled “Pre-built Contrast Theme”import '@toolbox-web/grid/themes/dg-theme-contrast.css';Windows High Contrast Mode
Section titled “Windows High Contrast Mode”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.
Accessibility-First Patterns
Section titled “Accessibility-First Patterns”These patterns are how we keep the grid accessible — and how you should keep your customizations accessible too.
1. Never replace content with color alone
Section titled “1. Never replace content with color alone”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>` }2. Don’t suppress focus indicators
Section titled “2. Don’t suppress focus indicators”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.
4. Label fragmented controls
Section titled “4. Label fragmented controls”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.
WCAG Compliance Checklist
Section titled “WCAG Compliance Checklist”| Criterion | Level | Status | Notes |
|---|---|---|---|
| 1.1.1 Non-text Content | A | ✅ | Icons have text alternatives |
| 1.3.1 Info and Relationships | A | ✅ | ARIA roles convey structure |
| 1.3.2 Meaningful Sequence | A | ✅ | DOM order matches visual order |
| 1.4.1 Use of Color | A | ✅ | Status not conveyed by color alone |
| 1.4.3 Contrast (Min) | AA | ✅ | Default theme meets 4.5:1 ratio |
| 1.4.11 Non-text Contrast | AA | ✅ | Focus indicators visible at 3:1 |
| 2.1.1 Keyboard | A | ✅ | All functions accessible via keyboard |
| 2.1.2 No Keyboard Trap | A | ✅ | Tab exits the grid; Escape exits editors |
| 2.4.3 Focus Order | A | ✅ | Logical focus order follows grid structure |
| 2.4.7 Focus Visible | AA | ✅ | Clear focus indicators |
| 4.1.2 Name, Role, Value | A | ✅ | ARIA attributes on all interactive elements |
What’s Automatic
Section titled “What’s Automatic”The grid handles most accessibility concerns out of the box:
- ARIA roles & attributes —
role="grid",role="row",role="gridcell",aria-rowcount,aria-colcount,aria-rowindex,aria-colindexare always set. When the Tree or Row Grouping plugin is active, the rows-body upgrades torole="treegrid"and every row carriesaria-level/aria-setsize/aria-posinsetper 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="...">orshell.header.titleconfig) - Windows High Contrast —
forced-colorsmedia query is built into core CSS - Live announcements — Sort, filter, selection, grouping, and editing changes are announced via
aria-liveregions (configurable viaa11yconfig) - Plugin ARIA — Selection, editing, filtering, tree, and grouping plugins manage their own ARIA attributes (
aria-selected,aria-readonly,aria-expanded,aria-multiselectable,aria-description)
Developer Responsibilities
Section titled “Developer Responsibilities”These require explicit action:
- Provide a grid label when not using a shell — Without
<tbw-grid-header title="...">, setgridAriaLabelin config, or setgridAriaLabelledByto theidof an existing heading next to the grid (aria-labelledbywins per WAI-ARIA precedence and suppressesaria-labelto avoid conflicting names), or addaria-labeldirectly on the element - Test with screen readers — NVDA (Windows), VoiceOver (macOS), Orca (Linux)
- Don’t override keyboard handling — The grid follows WAI-ARIA patterns; custom key listeners may conflict
- Use the contrast theme for additional a11y — Import
dg-theme-contrast.cssfor higher contrast beyond the built-in forced-colors support