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.

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.

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-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.
  • 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.css and dg-theme-large.css go 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.

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.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.

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”)
  • 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.

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 include:

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

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.

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.

#FlowStepsMust be announced
1Enter the gridTab to the gridAccessible name, grid (or treegrid) role, row and column counts
2Move between cells / Cell value, its column header, and the row/column position
3Sort a columnFocus a header, press EnterNew sort direction — and the header name without the sort glyph or the resize handle’s label
4Resize a columnTab to the resize handle, /The handle’s own name (Width of column <name>) and the resulting width
5Select a rowSpace, or activate the row checkboxSelected/unselected state, and the new selection count if a status region is enabled
6Select allActivate the header checkboxSelect all rows, and the resulting checked/mixed state
7Open the filter panelActivate a column’s filter buttonThat a panel opened, its name, and the first control inside it receiving focus
8Apply a filterChange a value, activate ApplyThe filtered row count, via the live region
9Expand a tree node on a parent rowExpanded state and the new child count; the row’s name must not be swallowed by the glyph
10Expand a detail rowActivate the row’s toggleThe button’s name flipping between Expand details and Collapse details, plus its expanded state
11Edit a cellEnter to open the editorThe editor’s role and current value, and that focus moved into it
12Commit an editEnterConfirmation of the committed value; on Escape, that the edit was discarded
13Open the context menuShift+F10 or the menu keymenu role, item count, and each item as you arrow through
14Escape every transient surfaceEscape from 7, 11, 13That 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.

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

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 operationPointer alternative
Resize a columnTap (don’t drag) the resize handle → a popover with − / width / + / Reset
Reorder a columnRight-click, long-press or Shift + F10 the header → Move left / right / to start / to end
Reorder a column groupSame menu on the group header cell → moves the whole fragment past its whole neighbour
Reorder a rowTap the row drag handle → Move up / down / to top / to bottom
Move a row to another gridSame menu → Copy to / Send to, one entry per connected grid
Select a cell rangeClick the first cell, then right-click / long-press the opposite corner → Extend selection to here
Resize a cell rangeTap a range corner handle to arm it, then tap the cell that corner should move to
Resize the tool panelTap the tool-panel splitter → the same − / width / + / Reset popover
Move a pivot fieldClick the field chip → Move up / down / to the other group zone / to Values / Remove
Reorder from the column panelClick the panel’s drag handle → Move up / down / to top / to bottom (group handles move the whole block)

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:

  1. 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.
  2. No gesture is taken over that the grid or the browser already answers. The one exception is the range-extend entry: with no ContextMenuPlugin registered, 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.
  3. 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.

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:

ControlHow it reaches 24 px
Tree, row-group, pivot and master-detail toggles; the row drag handle; the filter buttonA transparent overlay centred on the icon
Column resize handleAbsolutely 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 checkboxWrapped in a <label>, which makes the whole header cell the target
Row checkboxThe row click already selects, so the target is the cell
Tool-panel splitterStraddles 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:

TokenDefaultNotes
--tbw-touch-target-min24pxEvery pointer target in the grid
--tbw-tool-panel-resize-overhang12pxHalf the splitter’s target width. Raise it alongside the token above.

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.

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.

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.

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';

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.

ThemeImportText barAimed at
Contrastdg-theme-contrast.cssAAA (7:1)Low vision, glare, and users who need maximum separation
Largedg-theme-large.cssAAA (7:1)Low vision and motor impairment — 18px base text and larger hit targets
Standarddg-theme-standard.cssAA (4.5:1)A neutral, modern default
Bootstrapdg-theme-bootstrap.cssAA (4.5:1)Matching a Bootstrap 5 application
Materialdg-theme-material.cssAA (4.5:1)Matching a Material 3 application
Vibrantdg-theme-vibrant.cssAA (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.

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.

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.

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.10 ReflowAANothing 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 ContrastAAFocus indicators visible at 3:1 — verified by computed-ratio review, since axe does not check non-text contrast
1.4.12 Text SpacingAARows grow to fit user spacing, and text an ellipsis hides stays readable — see Reflow and text spacing
1.4.13 Content on Hover or FocusAATooltips are dismissible (Escape), hoverable, persistent, and shown on keyboard focus
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
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 MovementsAA (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 IdentificationAAn invalid cell carries aria-invalid and the validation message, not just a red border
3.3.2 Labels or InstructionsAAn injected editor is named after its column header unless it already names itself
4.1.2 Name, Role, ValueAARIA attributes on all interactive elements
4.1.3 Status MessagesAASort, filter, selection, load and commit are announced via aria-live without moving focus

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