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.
See also: Accessibility Conformance Report — the VPAT-style document for procurement, covering every WCAG 2.2 criterion and the EN 301 549 mapping · Touch Input — gesture map, long-press priority order, and touch browser quirks.
Our accessibility commitment
Section titled “Our accessibility commitment”We target WCAG 2.2 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.
- Focus is never hidden — plugins that overlay the rows viewport report the band they obscure, so keyboard navigation scrolls the focused cell clear of them rather than underneath. Sticky rows report the top/bottom band they cover (vertical scrolling); pinned columns report the left/right band (horizontal scrolling).
- Every shipped theme meets AA, and two meet AAA — all six themes clear 4.5:1 on every text pair in both light and dark mode;
dg-theme-contrast.cssanddg-theme-large.cssgo further to Level AAA (7:1). See Theme contrast.
See the full WCAG 2.2 AA conformance table below for the criteria we test against, or the Accessibility Conformance Report for the criterion-by-criterion VPAT-style breakdown, the evidence behind each claim, and the boundary between what the grid guarantees and what your application must supply.
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.2 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”)
- Empty state (“No data to display”)
The empty-state overlay is itself role="presentation". It renders inside the grid’s row
container, and role="grid" may only own rows and rowgroups — so the message is voiced through the
grid’s own live region instead of being marked up as a status of its own.
Configuring Announcements
Section titled “Configuring Announcements”Live region announcements are controlled by the a11y config. Turn them off entirely if your app already announces these state changes itself:
grid.gridConfig = { a11y: { announcements: false },};Individual messages are replaced through a11y.messages — see A11yMessages for the full list and their signatures. Since the defaults are English, that override is also the translation hook:
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”What is automated, and what is not. Every pull request runs three kinds of automated accessibility check: axe-core scans of the grid and of the documentation site, computed-style measurement of focus indicators, contrast and target size, and ARIA tree snapshots that pin the accessibility tree of the grid skeleton, the disclosure controls, and transient surfaces such as the filter panel and context menu. Those catch rule violations and silent regressions in the exposed semantics.
They do not tell you what a screen reader actually says. No assistive technology is driven in CI — announcements depend on the AT, the browser, and the user’s verbosity settings, none of which a headless browser models. Screen-reader verification is therefore a manual procedure, run against a release candidate. The checklist below is that procedure; it is also the one to follow when validating your own app.
Manual verification checklist
Section titled “Manual verification checklist”Run each row in at least one Windows pairing (NVDA + Chrome or Firefox) and one macOS pairing (VoiceOver + Safari). A flow passes when the announcement carries the information in the last column — the exact wording varies by AT and is not itself a defect.
| # | Flow | Steps | Must be announced |
|---|---|---|---|
| 1 | Enter the grid | Tab to the grid | Accessible name, grid (or treegrid) role, row and column counts |
| 2 | Move between cells | ↓ / → | Cell value, its column header, and the row/column position |
| 3 | Sort a column | Focus a header, press Enter | New sort direction — and the header name without the sort glyph or the resize handle’s label |
| 4 | Resize a column | Tab to the resize handle, ←/→ | The handle’s own name (Width of column <name>) and the resulting width |
| 5 | Select a row | Space, or activate the row checkbox | Selected/unselected state, and the new selection count if a status region is enabled |
| 6 | Select all | Activate the header checkbox | Select all rows, and the resulting checked/mixed state |
| 7 | Open the filter panel | Activate a column’s filter button | That a panel opened, its name, and the first control inside it receiving focus |
| 8 | Apply a filter | Change a value, activate Apply | The filtered row count, via the live region |
| 9 | Expand a tree node | → on a parent row | Expanded state and the new child count; the row’s name must not be swallowed by the glyph |
| 10 | Expand a detail row | Activate the row’s toggle | The button’s name flipping between Expand details and Collapse details, plus its expanded state |
| 11 | Edit a cell | Enter to open the editor | The editor’s role and current value, and that focus moved into it |
| 12 | Commit an edit | Enter | Confirmation of the committed value; on Escape, that the edit was discarded |
| 13 | Open the context menu | Shift+F10 or the menu key | menu role, item count, and each item as you arrow through |
| 14 | Escape every transient surface | Escape from 7, 11, 13 | That the surface closed and where focus landed |
Record the AT + browser + version for each run. A flow that regresses between releases is a bug worth filing even when axe still scores the page clean — that is precisely the class of defect automation cannot see.
Expected announcements, by AT
Section titled “Expected announcements, by AT”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
Pointer Alternatives to Dragging
Section titled “Pointer Alternatives to Dragging”WCAG 2.2 SC 2.5.7 Dragging Movements (Level AA) requires every operation that uses a dragging movement to also be achievable with a single pointer that never drags. It targets people who cannot reliably press, hold, move and release in one uninterrupted gesture — tremor, limited dexterity, head pointers, eye-gaze, switch devices and speech-driven mouse emulation.
A keyboard shortcut does not satisfy it. The W3C is explicit that keyboard equivalence only counts when the same operation is also exposed through controls a pointer can click or tap. The grid therefore ships a pointer path for every drag:
| Drag operation | Pointer alternative |
|---|---|
| Resize a column | Tap (don’t drag) the resize handle → a popover with − / width / + / Reset |
| Reorder a column | Right-click, long-press or Shift + F10 the header → Move left / right / to start / to end |
| Reorder a column group | Same menu on the group header cell → moves the whole fragment past its whole neighbour |
| Reorder a row | Tap the row drag handle → Move up / down / to top / to bottom |
| Move a row to another grid | Same menu → Copy to / Send to, one entry per connected grid |
| Select a cell range | Click the first cell, then right-click / long-press the opposite corner → Extend selection to here |
| Resize a cell range | Tap a range corner handle to arm it, then tap the cell that corner should move to |
| Resize the tool panel | Tap the tool-panel splitter → the same − / width / + / Reset popover |
| Move a pivot field | Click the field chip → Move up / down / to the other group zone / to Values / Remove |
| Reorder from the column panel | Click the panel’s drag handle → Move up / down / to top / to bottom (group handles move the whole block) |
Keeping it out of the way
Section titled “Keeping it out of the way”By design these alternatives reuse an affordance that already exists, so they cost no extra header width or visual noise. Three rules keep them from interfering with ordinary pointer use:
- No persistent chrome on hovering pointers. Nothing is added to the default UI. The inline buttons below are opt-in, and appear unprompted only where hover does not exist.
- No gesture is taken over that the grid or the browser already answers. The one exception is
the range-extend entry: with no
ContextMenuPluginregistered, the SelectionPlugin hosts a bare menu on right-click over a cell, which suppresses the native menu. Register the ContextMenuPlugin and the entry is injected into your menu instead, alongside your own items. - Nothing fires on the first half of an existing double-click. Tapping the resize handle waits out the double-click window (300 ms) before offering the width popover, so double-clicking the handle still resets the column without the popover flashing up.
If you would rather trade density for discoverability, switch the alternatives to dedicated buttons that are revealed on hover or focus:
gridConfig = { a11y: { // 'menu' (default) — reached through the existing handle or context menu // 'inline' — also render a dedicated button per affected header dragAlternatives: 'inline', },};Pointers that cannot hover (touch, most switch devices) always get the inline controls regardless of this setting — there is nothing for them to hover in order to reveal a control.
Target size
Section titled “Target size”WCAG 2.2 SC 2.5.8 asks that every pointer target measure at least 24 × 24 CSS pixels. It says nothing about the pointer type, so the minimum has to hold for a mouse as much as for a finger — and a grid whose whole value is density cannot answer that by making every row taller.
So the grid grows the target, not the box. Row heights, column widths, tree indentation and icon sizes are exactly what they were before the minimum existed:
| Control | How it reaches 24 px |
|---|---|
| Tree, row-group, pivot and master-detail toggles; the row drag handle; the filter button | A transparent overlay centred on the icon |
| Column resize handle | Absolutely positioned over the column border, so its width costs no layout. On the last column there is no neighbouring column to reach into, so the handle sits fully inside the cell instead and the cell’s trailing padding grows to match |
| Select-all checkbox | Wrapped in a <label>, which makes the whole header cell the target |
| Row checkbox | The row click already selects, so the target is the cell |
| Tool-panel splitter | Straddles the panel’s docked edge, half outside, so it never covers the panel’s own controls |
One small target is never allowed to overlap another. Where a resize handle would reach into the filter button beside it, the header cell’s trailing padding grows instead — by about four pixels, once, on resizable columns.
A coarse pointer additionally enlarges the visible box of several controls. That is a comfort choice layered on top of the target minimum, not the thing that satisfies it — the 24 px holds on a mouse either way.
Everything is driven from one token, so raising the minimum raises every target at once:
tbw-grid { --tbw-touch-target-min: 44px; /* SC 2.5.5 Target Size (Enhanced), AAA */ --tbw-tool-panel-resize-overhang: 22px; /* half of it, for the splitter */}44 px also matches Apple’s Human Interface Guidelines recommendation for primary touch targets.
The Shell tool-panel splitter is the one control that needs a second token.
Because its target straddles the panel’s docked edge, half of it sits outside the panel — which
means the panel’s clip region has to widen to match. Browsers reject a calculated
overflow-clip-margin, so the overhang cannot be derived from --tbw-touch-target-min and has to
be stated separately:
| Token | Default | Notes |
|---|---|---|
--tbw-touch-target-min | 24px | Every pointer target in the grid |
--tbw-tool-panel-resize-overhang | 12px | Half the splitter’s target width. Raise it alongside the token above. |
Reflow and text spacing
Section titled “Reflow and text spacing”Two criteria govern what happens when the page gets narrow or the reader makes the text roomier. Both come down to the same promise: nothing may become unreachable.
Reflow (SC 1.4.10)
Section titled “Reflow (SC 1.4.10)”SC 1.4.10 Reflow asks that content survive a 320 CSS-pixel viewport without a second scrollbar — but it exempts content that “requires two-dimensional layout for usage or meaning”, and it names data tables as the example. A grid is that exemption: the table keeps its horizontal scrollbar, because collapsing columns into a single file would destroy the row-to-column relationships that make it a table at all.
The exemption covers the table and nothing else. Everything the grid puts around the table has to reflow properly:
- The tool panel is clamped to the grid it docks into. A panel wider than its container would be clipped with no scrollbar to reach what was cut off — the chip labels and half the section headings would simply be gone. Both the auto-open width and a width you dragged yourself are held to the container.
- The header collapses gracefully. The title truncates before the toolbar buttons do, so the controls stay reachable no matter how narrow the grid gets.
- Card mode is available for the table itself. If you would rather the table reflowed than scrolled, the Responsive plugin restacks each row as a card below a breakpoint you choose. It watches the grid element, not the viewport, so a grid in a narrow sidebar reflows on a wide screen too.
Text spacing (SC 1.4.12)
Section titled “Text spacing (SC 1.4.12)”SC 1.4.12 Text Spacing lets a reader override line height, letter spacing and word spacing, and requires that no content or function be lost as a result. Text set that way needs roughly a third more room, which is exactly the room a grid does not have.
Vertically the grid gives way. Rows are sized from a measurement of the rendered content rather than a fixed height, so a taller line box makes the row taller and the virtualized layout follows it — text never overflows its row or collides with the row below.
Horizontally a column has a fixed width, so wider text runs past it and picks up an ellipsis. The SC permits that, on one condition: the hidden text must still be available. So hovering a cell whose text does not fit reveals the whole value. The title is resolved on hover rather than at render time, so cells that fit stay tooltip-free and the render path is untouched. Install the Tooltip plugin and it takes over with a styled popover that is keyboard-reachable, hoverable and dismissible with Escape.
If you would rather nothing truncated at all, let cells wrap:
tbw-grid { --tbw-cell-white-space: normal;}Rows then grow to fit their tallest cell. This costs vertical space on every row, so it is a choice rather than the default — but it composes with everything else, including virtualization.
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';Theme contrast
Section titled “Theme contrast”Every shipped theme clears WCAG Level AA, and two clear Level AAA. There is no theme in the box that you have to trade accessibility for.
| Theme | Import | Text bar | Aimed at |
|---|---|---|---|
| Contrast | dg-theme-contrast.css | AAA (7:1) | Low vision, glare, and users who need maximum separation |
| Large | dg-theme-large.css | AAA (7:1) | Low vision and motor impairment — 18px base text and larger hit targets |
| Standard | dg-theme-standard.css | AA (4.5:1) | A neutral, modern default |
| Bootstrap | dg-theme-bootstrap.css | AA (4.5:1) | Matching a Bootstrap 5 application |
| Material | dg-theme-material.css | AA (4.5:1) | Matching a Material 3 application |
| Vibrant | dg-theme-vibrant.css | AA (4.5:1) | A saturated, high-energy identity |
The bar applies to every foreground/background pair the theme produces — body text, muted text, header text, alternating rows, hovered rows, selected rows, and text on the accent colour — in both light and dark mode. Translucent row tints are composited over the grid background before they are scored, so the ratio reflects what is actually painted. Non-text UI colours (the focus ring, and the strong border that bounds the cell editor’s input) clear SC 1.4.11’s 3:1.
All of it is asserted in a real browser on every CI run by
apps/docs-e2e/tests/theme-contrast.spec.ts, so a token edit that drops any pair below its theme’s
bar fails the build.
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”The criteria most often asked about, abridged. The Accessibility Conformance Report is the authority: it covers every Level A and AA criterion in WCAG 2.2, maps them onto EN 301 549 for the EU, and includes the ones that are not applicable to a component and the ones where your own content decides the outcome.
Each ✅ links to the test that verifies it.
| 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.10 Reflow | AA | ✅ | Nothing around the table needs a second scrollbar at 320 px; the table itself uses the data-table exception — see Reflow and text spacing |
| 1.4.11 Non-text Contrast | AA | ✅ | Focus indicators visible at 3:1 — verified by computed-ratio review, since axe does not check non-text contrast |
| 1.4.12 Text Spacing | AA | ✅ | Rows grow to fit user spacing, and text an ellipsis hides stays readable — see Reflow and text spacing |
| 1.4.13 Content on Hover or Focus | AA | ✅ | Tooltips are dismissible (Escape), hoverable, persistent, and shown on keyboard focus |
| 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 |
| 2.4.11 Focus Not Obscured (Min) | AA (2.2) | ✅ | Keyboard navigation scrolls the focused row clear of overlays such as sticky rows, and the focused cell clear of pinned columns |
| 2.5.7 Dragging Movements | AA (2.2) | ✅ | Every drag has a single-pointer alternative — see Pointer Alternatives to Dragging |
| 2.5.8 Target Size (Minimum) | AA (2.2) | ✅ | Every control answers a pointer across at least 24 × 24 px, on a mouse as well as a finger — see Target size |
| 3.3.1 Error Identification | A | ✅ | An invalid cell carries aria-invalid and the validation message, not just a red border |
| 3.3.2 Labels or Instructions | A | ✅ | An injected editor is named after its column header unless it already names itself |
| 4.1.2 Name, Role, Value | A | ✅ | ARIA attributes on all interactive elements |
| 4.1.3 Status Messages | AA | ✅ | Sort, filter, selection, load and commit are announced via aria-live without moving focus |
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