<!-- https://svgrid.com/docs/help/accessibility/ - SvGrid documentation as markdown. Index of every page: https://svgrid.com/llms.txt -->

# Accessibility

SvGrid implements the WAI-ARIA 1.2 grid pattern with full keyboard
navigation and a screen-reader announcement layer. This page documents
exactly what the grid does, where the responsibility line sits between
the library and your code, and how to verify conformance.

![An ARIA grid whose rows carry aria-rowindex and cells carry aria-colindex, with the active cell that arrow keys move named by aria-activedescendant, and an aria-live region for status messages.](https://svgrid.com/docs-media/grid-a11y.svg)

Live demo - high-contrast toggle, `aria-live` log, and a focus-trap
walk-through:

> Live demo: Accessibility - https://svgrid.com/demos/17-accessibility/

## TL;DR

| Standard                | Status                                                                 |
| ----------------------- | ---------------------------------------------------------------------- |
| WAI-ARIA 1.2 grid pattern | Implemented (see [Roles & properties](#roles--properties)).         |
| WCAG 2.2 AA             | Self-assessed, criterion by criterion, in the [Accessibility Conformance Report](https://svgrid.com/docs/compliance/vpat/), gaps included. Structure is audited by `axe-core` in CI, in jsdom and in a real browser. Contrast is enforced in CI for **all 20 built-in presets, light and dark**; a custom theme is yours to verify. |
| Keyboard navigation     | Navigation, selection, sorting, filtering and editing; see [Keyboard map](#keyboard-map). Row dragging and moving columns by drag have no keyboard path yet; the ACR lists them. |
| Screen-reader announcements | Filter results and bulk selection changes, via a single `aria-live=polite` region. See [What the grid announces](#what-the-grid-announces). |
| Reduced motion          | The cell flash and the loading shimmer stop under `prefers-reduced-motion: reduce`. |
| Forced colors / high contrast | The active cell switches to a system `Highlight` outline (a box-shadow ring would vanish). Tested in a browser with forced colors emulated. |

## Roles & properties

| Element                        | Role           | Notable attributes                                                                                  |
| ------------------------------ | -------------- | --------------------------------------------------------------------------------------------------- |
| `<table>` root                 | `grid` (`treegrid` with `treeData` or server grouping) | `tabindex="0"`, `aria-activedescendant` pointing at the active cell, `aria-rowcount`, `aria-colcount` |
| `<thead>`, `<tbody>`, `<tfoot>` | `rowgroup`    |                                                                                                     |
| Body `<tr>`                    | `row`          | `aria-rowindex` (1-based, absolute, so it holds under virtualization); `aria-level` / `aria-expanded` on tree and group rows |
| Header `<th>`                  | `columnheader` | `aria-sort` (`"ascending"` / `"descending"` / `"none"`) on sortable columns                         |
| Body `<td>`                    | `gridcell`     | `id`, `aria-colindex`, `aria-rowindex`, `aria-selected` (row selection); `aria-invalid` plus the message when `validate` rejects the value |
| Selection checkboxes           | `checkbox`     | `aria-checked`, `"mixed"` on the header when some rows are selected                                 |
| Filter popover                 | `menu`         |                                                                                                     |
| Cell editor input              | `textbox` (text, number and multi-line editors) | `aria-label` is the column header                                |
| Live announcement region       | `status` (`aria-live="polite"` on a visually-hidden `<div>`) |                                                                       |

Header rows carry no `aria-rowindex` yet, and a cell range selection (as
opposed to row selection) is not exposed through `aria-selected`. Both are
listed in the [ACR](https://svgrid.com/docs/compliance/vpat/).

The grid never sets `role="presentation"` on table elements - screen
readers receive a fully-structured grid.

## Keyboard map

Standard ARIA grid navigation, plus a handful of grid-specific
shortcuts:

### Navigation

| Key                 | Action                                                                |
| ------------------- | --------------------------------------------------------------------- |
| Tab / Shift+Tab     | Move the active cell right / left, wrapping at row ends. On the last cell (first, for Shift+Tab) focus leaves the grid for the next (previous) element on the page. Ctrl+End then Tab, or Ctrl+Home then Shift+Tab, leaves from anywhere. |
| Arrow Up/Down       | Move active cell one row.                                             |
| Arrow Left/Right    | Move active cell one column.                                          |
| Home                | Active cell → first column in the row.                                |
| End                 | Active cell → last column in the row.                                 |
| Ctrl/Cmd + Home     | Active cell → top-left of the grid.                                   |
| Ctrl/Cmd + End      | Active cell → bottom-right.                                           |
| Ctrl/Cmd + Arrow    | Reserved. The grid does not move the active cell; `enableSheet()` in `@svgrid/enterprise` binds it to Excel's jump-to-edge-of-data-region. See [Excel keyboard shortcuts](https://svgrid.com/docs/help/cells/keyboard-shortcuts/). |
| Page Up / Page Down | Scroll a viewport's worth of rows.                                    |

### Selection (when `rowSelectionFeature` is on)

| Key                       | Action                                                          |
| ------------------------- | --------------------------------------------------------------- |
| Space                     | Toggle the active row's selection.                              |
| Ctrl/Cmd + A              | Not bound by the grid; the browser's select-all applies. The header checkbox selects every row, and `api.selectCells` selects ranges. |
| Shift + Arrow keys        | Extend the cell range from the active cell.                     |
| Ctrl/Cmd + Click on a cell | Add a cell range to the selection.                             |

### Sort + filter

| Key                              | Action                                                   |
| -------------------------------- | -------------------------------------------------------- |
| Enter / Space (on a header's sort button) | Toggle sort: `none → asc → desc → none`.        |
| Enter / Space (on a header's filter button) | Open the filter menu.                         |
| Escape (in a filter menu)        | Close the menu.                                          |

Header buttons are reached with the mouse or, from outside the grid, with
Tab; they are not part of the cell navigation.

### Editing

| Key                         | Action                                                |
| --------------------------- | ----------------------------------------------------- |
| F2 / double-click           | Enter edit mode on the active cell. (Enter moves down, as in a spreadsheet.) |
| Type any character          | Start editing with that character as the first input. |
| Enter / Tab (while editing) | Commit and move to the next cell / row.               |
| Escape (while editing)      | Cancel; revert the cell.                              |
| Delete / Backspace          | Clear the active cell (for cells whose editor supports clearing). |

### Tree rows (when you build them)

The keyboard handler in [Tree rows](https://svgrid.com/docs/help/rows/tree-rows/) adds:

| Key                 | Action (active cell in name column) |
| ------------------- | ----------------------------------- |
| Arrow Right         | Expand a collapsed node.            |
| Arrow Left          | Collapse an expanded node.          |
| Enter / Space       | Toggle.                             |

On a grouped grid, a tree (`getSubRows`) or a
[server-side row model](https://svgrid.com/docs/help/server/server-grouping/#keyboard-and-accessibility)
the grid handles these itself, and **Ctrl+Enter** on any cell of a group
row toggles it; on the server model a leaf with a detail panel
(`renderDetailRow`) opens or closes it with the same key.

## Screen-reader announcements

The grid shares one visually-hidden `aria-live="polite"` region with the rest of
the component library, and is deliberately sparing about what it puts there.

### What the grid announces

| Event | What is said |
| --- | --- |
| A filter changes the matching rows | `"12 of 250 rows match the current filters"`, or `"No rows match the current filters"` |
| Every filter is cleared | `"Filters cleared, showing all 250 rows"` |
| A bulk selection change | `"250 rows selected"` / `"Selection cleared"` |

Filter announcements are debounced by 400 ms, so typing in the search box
announces the count you stopped on rather than one for every prefix. They are
also skipped entirely under `externalFilter`, because there the server decides
what matched and the local count would describe only the page in hand.

### What it deliberately does not announce

This is the more important half. A live region is not the only way a screen
reader learns something, and announcing what the accessibility tree already
carries makes the grid talk over itself:

- **The cell you moved to.** `aria-activedescendant` on the grid points at it,
  so the reader announces the cell, its column header and its row position
  natively.
- **Sort state.** Carried by `aria-sort` on the column header, read when focus
  lands on it.
- **Whether the row you are on is selected.** Carried by `aria-selected`.
- **Selecting or deselecting a single row.** The focused row is announced with
  its new selected state; adding `"1 row selected"` on top would be a second,
  redundant utterance. Only changes of more than one row, which cannot have come
  from a single focus move, are announced.

### Translating them

Announcements are full sentences, so unlike the single-word labels elsewhere in
`localeText` they take `{placeholders}` and you control the word order:

The examples on this page run against these rows:

```svelte
<script lang="ts">
  import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'

  type Person = {
    id: number
    name: string
    department: string
    city: string
    age: number
    salary: number
  }

  const people: Person[] = [
    { id: 1, name: 'Ada Lovelace',   department: 'Engineering', city: 'London',   age: 36, salary: 142000 },
    { id: 2, name: 'Grace Hopper',   department: 'Engineering', city: 'New York', age: 45, salary: 168000 },
    { id: 3, name: 'Linus Torvalds', department: 'Platform',    city: 'Portland', age: 54, salary: 155000 },
    { id: 4, name: 'Radia Perlman',  department: 'Networking',  city: 'Seattle',  age: 49, salary: 161000 },
    { id: 5, name: 'Barbara Liskov', department: 'Platform',    city: 'Boston',   age: 52, salary: 172000 },
  ]

  const columns: GridColumns<Person> = [
    { field: 'name',       header: 'Name',       width: 190 },
    { field: 'department', header: 'Department', width: 150 },
    { field: 'city',       header: 'City',       width: 130 },
    { field: 'age',        header: 'Age',        width: 80 },
    { field: 'salary',     header: 'Salary',     width: 130, format: { type: 'currency', currency: 'USD' } },
  ]
</script>
```

```svelte
<SvGrid
  localization={{
    text: {
      announceFilterResults: '{visible} van de {total} rijen komen overeen',
      announceNoMatches: 'Geen rijen komen overeen',
      announceFiltersCleared: 'Filters gewist, alle {total} rijen worden getoond',
      announceRowsSelected: '{count} rijen geselecteerd',
      announceSelectionCleared: 'Selectie gewist',
    },
  }}
/>
```

An unknown placeholder is left in the output rather than blanked, so a typo in
an override is visible instead of silently eating the number.

## Focus management

- The `<table>` is the grid's single tab stop. Focus stays on it, and
  `aria-activedescendant` names the active cell; cells themselves carry no
  `tabindex`.
- The active cell is drawn with an accent ring. It is hidden while the grid
  sits untouched at its seeded first cell, and shown as soon as a click, a
  key or the api activates a cell, or the keyboard brings focus to the grid.
- Tab moves between cells and leaves the grid at either end (see the
  [keyboard map](#navigation)).
- Editing transfers focus to the editor `<input>`, which is named after the
  column; committing returns focus to the grid.
- The filter popover moves focus to its first control when it opens. Grid
  menus do not yet return focus to the button that opened them on close;
  the [ACR](https://svgrid.com/docs/compliance/vpat/) lists it under 2.4.3.

## High-contrast / forced-colors mode

Under Windows High Contrast (`forced-colors: active`) the active cell gets a
2 px outline in the system `Highlight` colour, because forced colors removes
the box-shadow ring the grid draws otherwise. `tests/e2e/grid-a11y.spec.ts`
checks it with forced colors emulated in Chromium.

## Right to left

Set `dir="rtl"` on the page or any element above the grid and the layout
mirrors: the row-number gutter and every pinned column stick to the right
edge and stay there while the cells scroll under them, because the pins
are logical (`inset-inline-start`, `inset-inline-end`) rather than a
physical `left`. Nothing to opt into and no second stylesheet.

## Reduced motion

When `prefers-reduced-motion: reduce` matches:

- The cell flash (`cellFlash`) does not animate.
- The loading-row shimmer stops.

Keyboard scrolling is always instant, so there is no smooth scroll to turn
off. Chevron rotations still use a short transition. You don't need to opt
in; both rules are plain CSS media queries.

## What you're responsible for

The grid can't know:

- **Contrast** of your custom CSS variables. Use the [Tailwind
  integration](https://svgrid.com/docs/help/tailwind/) page's contrast notes when picking
  `--sg-fg` / `--sg-bg` pairs.
- **Labels** for cells whose content is purely visual (e.g. a status
  pill that's just a coloured dot). Set `aria-label` on the cell
  content yourself.
- **Reading order** of header groups. The grid emits group headers
  with `aria-colspan` correctly, but if your group label is "Q1"
  alone, screen readers say "Q1" - consider "Q1 2025" to give context.
- **The wording of validation messages.** A cell failing a column's
  `validate` hook is marked `aria-invalid` and the returned string is
  carried to assistive technology, so return a message that says how to
  fix it ("Score must be at least 90"), not just that something is
  wrong ("Invalid"). Returning `false` marks the cell invalid with no
  message at all, which leaves a screen-reader user without a reason.

## How to verify

1. **Keyboard sweep.** Unplug your mouse. Tab in, navigate every cell,
   sort, filter, edit, undo. If any action is unreachable, file an issue.
2. **NVDA + VoiceOver.** Each makes different choices about announcement
   verbosity. Test both.
3. **Lighthouse accessibility audit.** Run it against your own build. Most
   deltas are contrast issues in your theme rather than grid markup, so
   check those first.

   > We run `axe-core` against a rendered grid in CI (`a11y.axe.test.ts`),
   > covering the plain grid, the filter row, row selection and pagination.
   > That suite runs in jsdom, which does no layout, so axe's own
   > colour-contrast rule is disabled there.
   >
   > Contrast is covered separately and more thoroughly: `themes/contrast.test.ts`
   > computes WCAG ratios for **every built-in preset in both light and dark**
   > and fails the build below AA. Body text, secondary text, header text, text
   > on zebra / hovered / selected rows, and text on accent-filled controls all
   > have to clear 4.5:1, and the accent has to clear 3:1 where it signals focus
   > or selection. So the presets are checked exhaustively rather than whichever
   > one a browser test happened to load. What is still yours to verify is a
   > **custom** theme.
4. **axe-core in your e2e suite.**
   ```ts
   import AxeBuilder from '@axe-core/playwright'
   const results = await new AxeBuilder({ page })
     .include('.sv-grid-container')
     .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
     .analyze()
   expect(results.violations).toEqual([])
   ```

## Frequently asked questions

### Is SvGrid accessible / WCAG compliant?

SvGrid implements the WAI-ARIA 1.2 grid pattern: `role="grid"` structure, full
keyboard navigation (arrows, Home/End, Page Up/Down, Ctrl+Home/End), a focus
ring on the active cell, and an `aria-live` announcement layer. Final WCAG
conformance also depends on your own cell content and color choices - this page
documents exactly where that line sits.

### Does SvGrid work with screen readers?

The grid exposes the ARIA grid roles and states, and emits `aria-live`
announcements for filtering and bulk selection changes. Sort state is read
from `aria-sort` on the header rather than announced. No scripted NVDA, JAWS
or VoiceOver pass has been recorded yet; the
[ACR](https://svgrid.com/docs/compliance/vpat/) says so where it matters. Because it renders real DOM (not canvas), the content is
also selectable and machine-readable.

### How do I verify accessibility in my app?

Run axe-core against the rendered grid (recipe in the testing guide) and test
keyboard-only navigation. Pair it with the high-contrast focus toggle and the
`--sg-*` contrast tokens to meet your target contrast ratios.

## More examples

### Keyboard shortcuts + a11y

Ctrl+K command palette, Ctrl+/ cheat sheet, vim-style gg / G chord nav. Layers on top of the grid's WAI-ARIA grid pattern.

> Live demo: Keyboard shortcuts + a11y - https://svgrid.com/demos/65-keyboard-shortcuts/

### High-contrast theme

WCAG 2.2 AAA-grade preset for accessibility procurement. Token block opts a subtree into the high-contrast skin while the rest of the page stays standard. Light + dark.

> Live demo: High-contrast theme - https://svgrid.com/demos/96-high-contrast-theme/

## Try the keyboard

Everything below is keyboard-reachable. Tab once to enter the grid, then arrow
between cells - the grid is a single tab stop, not one per cell, which is what
makes a 10,000-row table usable without a mouse.

```svelte
<SvGrid data={people} {columns} sortable filterable enableCellSelection editable />
```

Try: Tab in, arrows to move, Enter to edit, Escape to cancel, Shift+arrows to
extend a range, Ctrl/Cmd+C to copy it.

## What a screen reader is told

The grid root carries `role="grid"` with `aria-rowcount` and `aria-colcount`,
headers carry `aria-sort`, and rows and cells carry their index. Sort a column
in the example above and inspect the header: the `aria-sort` attribute flips
with it, which is how a screen reader user knows the order changed at all.

```svelte
<script lang="ts">
  let report = $state('(sort a column)')

  function inspect() {
    const grid = document.querySelector('[role="grid"], [role="treegrid"]')
    const sorted = document.querySelectorAll('[role="columnheader"][aria-sort]')
    report =
      'role=' + (grid?.getAttribute('role') ?? 'missing') +
      ' | aria-rowcount=' + (grid?.getAttribute('aria-rowcount') ?? 'missing') +
      ' | headers with aria-sort: ' + sorted.length
  }
</script>

<SvGrid data={people} {columns} sortable />

<button type="button" onclick={inspect}>Inspect the ARIA</button>
<p><code>{report}</code></p>
```

## See also

- [Browser support](https://svgrid.com/docs/help/browser-support/) - the `ResizeObserver` /
  Pointer Events floor every assistive-tech tool relies on.
- [Tailwind integration](https://svgrid.com/docs/help/tailwind/) - the `--sg-*` tokens that
  control contrast.
- [Testing your grid](https://svgrid.com/docs/help/testing/) - includes an axe-core recipe.

---

SvGrid is the Svelte 5 data grid (`npm install @svgrid/grid`, MIT). This page: https://svgrid.com/docs/help/accessibility/ . All docs: https://svgrid.com/llms.txt
