Accessibility

WAI-ARIA grid, keyboard navigation, aria-live announcements, focus toggle.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Keyboard & Accessibility). See the SvGrid documentation for the full API.

About this example

The WAI-ARIA grid pattern built into the Svelte 5 data grid: role grid, row, columnheader and gridcell, aria-rowcount and aria-colcount reflecting the visible model, aria-rowindex and aria-colindex on every row and cell, aria-sort on headers and a focused active cell. Arrow keys move between cells, Home and End jump to row edges, Page Up and Down move a page, F2 or Enter starts editing and Ctrl+Home and Ctrl+End reach the grid edges. The demo adds an aria-live status region for sort, filter and selection changes, a panel showing the active cell's ARIA state, a toggleable high-contrast focus outline and a shortcut cheat sheet.

SvGrid ships with the WAI-ARIA grid pattern built in:

  • role="grid" / role="row" / role="columnheader" / role="gridcell" are applied through the helpers in src/a11y.ts.
  • aria-rowcount / aria-colcount reflect the visible model.
  • Each row + cell gets an aria-rowindex / aria-colindex.
  • The active cell carries the focus and DOM id. Headers expose aria-sort="ascending|descending|none".
  • Arrow keys move between cells. Home/End jump to row edges. Page Up/Down move by a page. F2 / Enter starts editing. Ctrl+Home / Ctrl+End jump to the grid edges.

This demo adds:

  • A live role="status" region that announces sort / filter / selection changes to screen readers.
  • A "Show ARIA state" panel that surfaces the values a screen reader would read for the active cell.
  • A high-contrast focus outline you can toggle.
  • A keyboard-shortcut cheat sheet pinned to the side.

Imports, features and API used

Imports: @svgrid/grid, ../shared/seed

Table features registered: rowSortingFeature, columnFilteringFeature, rowSelectionFeature

Columns: firstName (First name), lastName (Last name), department (Department), country (Country), age (Age), salary (Salary), performance (Performance)

Frequently asked questions

Do I need to add ARIA attributes myself?

No. The grid applies the roles, indexes, counts and aria-sort itself and manages focus on the active cell. What the demo adds is an app-level live region announcing state changes.

How do I announce changes to screen readers?

Keep a role='status' element and write a short message into it from onSortingChange, onFiltersChange and onRowSelectionChange, as the demo does. Screen readers read the new text without moving focus.

Which keys does the grid handle?

Arrows, Home, End, Page Up, Page Down, Ctrl+Home, Ctrl+End, Enter and F2 for editing, Escape to cancel and Space to toggle selection when checkboxes are on. Cells use a roving tabindex, so a single Tab moves focus out of the grid.

Related documentation

Related articles

  • Accessibility from the Ground Up - How SvGrid bakes WAI-ARIA roles and keyboard navigation into the core - not as a post-launch checkbox, but as a design constraint that shaped every feature.
  • Keyboard Navigation and Accessibility in SvGrid - WAI-ARIA grid semantics, roving tabindex, and live-region announcements are built into SvGrid from the start. Here is what that means in practice and where custom cells require your attention.
  • Choosing the Most Accessible Svelte Data Grid - A practical guide to testing data grid accessibility - ARIA roles, keyboard navigation, focus management under virtualization, and screen-reader behavior - so you can verify claims yourself.

Source code (17-accessibility.svelte)

<script lang="ts">
  /**
   * 17. Accessibility
   * -----------------
   * SvGrid ships with the WAI-ARIA grid pattern built in:
   *
   *   - role="grid" / role="row" / role="columnheader" / role="gridcell"
   *     are applied through the helpers in `src/a11y.ts`.
   *   - `aria-rowcount` / `aria-colcount` reflect the visible model.
   *   - Each row + cell gets an `aria-rowindex` / `aria-colindex`.
   *   - The active cell carries the focus and DOM `id`. Headers expose
   *     `aria-sort="ascending|descending|none"`.
   *   - Arrow keys move between cells. Home/End jump to row edges.
   *     Page Up/Down move by a page. F2 / Enter starts editing.
   *     Ctrl+Home / Ctrl+End jump to the grid edges.
   *
   * This demo adds:
   *
   *   - A live `role="status"` region that announces sort / filter /
   *     selection changes to screen readers.
   *   - A "Show ARIA state" panel that surfaces the values a screen
   *     reader would read for the active cell.
   *   - A high-contrast focus outline you can toggle.
   *   - A keyboard-shortcut cheat sheet pinned to the side.
   */
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
    type GridColumns,
    type SvGridApi,
  } from '@svgrid/grid'
  import { makePeople, type Person } from '../shared/seed'

  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
  })

  const rows = makePeople(120)
  let api = $state<SvGridApi<typeof features, Person> | null>(null)

  const columns: GridColumns<Person> = [
    { field: 'firstName',  header: 'First name', editorType: 'text', width: 130 },
    { field: 'lastName',   header: 'Last name',  editorType: 'text', width: 130 },
    { field: 'department', header: 'Department', editorType: 'text', width: 140 },
    { field: 'country',    header: 'Country',    editorType: 'text', width: 110 },
    { field: 'age',        header: 'Age',        editorType: 'number', width: 80 },
    {
      field: 'salary',
      header: 'Salary',
      editorType: 'number',
      width: 130,
      format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },
    },
    {
      field: 'performance',
      header: 'Performance',
      editorType: 'number',
      width: 110,
    },
  ]

  let highContrast = $state(false)
  let announcement = $state('')
  let selectedCount = $state(0)
  let sortLabel = $state('none')
  let filterCount = $state(0)
  let activeCellInfo = $state('row 1, column "First name"')

  // Announce changes to screen readers. `role="status"` (aria-live="polite")
  // queues announcements without interrupting the user.
  function announce(message: string) {
    // Briefly clear then set so consecutive identical messages still get
    // re-announced. Some assistive tech dedupes on string equality.
    announcement = ''
    setTimeout(() => (announcement = message), 30)
  }

  // Read the live ARIA state of the focused cell. We piggyback on the DOM
  // attributes the grid writes - that's what a screen reader actually sees.
  function readActiveCellAria() {
    const active = document.querySelector<HTMLElement>('.sv-grid-cell-active')
    if (!active) return
    const row = active.getAttribute('aria-rowindex') ?? '?'
    const colId = active.getAttribute('data-col-id') ?? '?'
    const colDef = columns.find((c) => c.field === colId)
    const colName = (colDef?.header as string) ?? colId
    activeCellInfo = `row ${row}, column "${colName}"`
  }

  $effect(() => {
    // Update the live active-cell readout on every focus + keydown so the
    // sidebar stays in sync with the cursor.
    const update = () => readActiveCellAria()
    document.addEventListener('focusin', update)
    document.addEventListener('keyup', update)
    return () => {
      document.removeEventListener('focusin', update)
      document.removeEventListener('keyup', update)
    }
  })

  const SHORTCUTS: Array<{ keys: string; what: string }> = [
    { keys: '↑ ↓ ← →',        what: 'Move active cell by one' },
    { keys: 'Home / End',     what: 'First / last cell in row' },
    { keys: 'Ctrl + Home/End', what: 'First / last cell in grid' },
    { keys: 'Page Up / Down', what: 'Move by viewport-page' },
    { keys: 'Shift + arrows', what: 'Extend cell-range selection' },
    { keys: 'Enter / F2',     what: 'Start editing the active cell' },
    { keys: 'Esc',            what: 'Cancel edit, close menu' },
    { keys: 'Tab',            what: 'Commit edit, move right' },
    { keys: 'Space',          what: 'Toggle row selection' },
    { keys: 'Ctrl + C / V',   what: 'Copy / paste cell range (TSV)' },
  ]
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3" class:hc-focus={highContrast}>
  <div class="flex flex-wrap items-center gap-3 text-sm shrink-0">
    <label class="flex items-center gap-2">
      <input type="checkbox" bind:checked={highContrast} class="rounded" />
      High-contrast focus outline
    </label>
    <span class="ml-auto a11y-muted">
      Try the grid with your keyboard - the sidebar shows what a screen reader hears.
    </span>
  </div>

  <div class="grid gap-3 flex-1 min-h-0 lg:grid-cols-[minmax(0,1fr)_280px]">
    <div class="min-h-0 min-w-0">
      <SvGrid responsive={true}
      columnResize
        data={rows}
        columns={columns}
        features={features}
        filterMode="menu"
        selectionMode="both"
        showRowSelection={true}
        showRowNumbers={true}
        showPagination={true}
        pageSize={20}
        enableInlineEditing={true}
        enableCellSelection={true}
        rowHeight={36}
        containerHeight="100%"
        fitColumns={true}
        onRowSelectionChange={(sel, selRows) => {
          selectedCount = selRows.length
          announce(`${selRows.length} row${selRows.length === 1 ? '' : 's'} selected`)
        }}
        onSortingChange={(s) => {
          if (!s.length) {
            sortLabel = 'none'
            announce('Sort cleared')
          } else {
            const c = s[0]!
            const col = columns.find((col) => col.field === c.id)
            const name = (col?.header as string) ?? c.id
            sortLabel = `${name} (${c.desc ? 'descending' : 'ascending'})`
            announce(`Sorted by ${name}, ${c.desc ? 'descending' : 'ascending'}`)
          }
        }}
        onFiltersChange={(f) => {
          filterCount = f.columns.length + (f.global ? 1 : 0)
          if (filterCount === 0) announce('Filters cleared')
          else announce(`${filterCount} filter${filterCount === 1 ? '' : 's'} active`)
        }}
        onApiReady={(next) => (api = next)}
      />
    </div>

    <aside class="a11y-panel rounded border p-3 overflow-y-auto text-sm">
      <h3 class="font-semibold mb-2">ARIA state</h3>
      <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 mb-3">
        <dt class="a11y-muted">Active cell</dt>
        <dd class="tabular-nums">{activeCellInfo}</dd>
        <dt class="a11y-muted">Selected rows</dt>
        <dd class="tabular-nums">{selectedCount}</dd>
        <dt class="a11y-muted">Sort</dt>
        <dd>{sortLabel}</dd>
        <dt class="a11y-muted">Filters</dt>
        <dd class="tabular-nums">{filterCount}</dd>
      </dl>

      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        class="a11y-live mb-3 rounded px-2 py-1 text-xs italic min-h-[1.8em]"
      >
        {announcement || '(announcements will appear here)'}
      </div>

      <h3 class="font-semibold mb-2">Keyboard shortcuts</h3>
      <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
        {#each SHORTCUTS as s, i (i)}
          <dt class="a11y-key font-mono whitespace-nowrap">{s.keys}</dt>
          <dd class="a11y-muted">{s.what}</dd>
        {/each}
      </dl>
    </aside>
  </div>
</section>

<style>
  /* Panel chrome follows the active grid theme. */
  .a11y-panel { border-color: var(--sg-border, #e2e8f0); }
  .a11y-muted { color: var(--sg-muted, #64748b); }
  .a11y-key { color: var(--sg-fg, #334155); }
  .a11y-live {
    background: var(--sg-bg-subtle, var(--sg-header-bg, #f8fafc));
    color: var(--sg-fg, #475569);
  }

  /* Toggleable high-contrast focus ring for users who need a stronger cue
   * than the default browser outline. The 3-px outline + 1-px ring stays
   * inside the cell so it doesn't overlap neighbours. */
  :global(.hc-focus .sv-grid-cell-active),
  :global(.hc-focus .sv-grid-column:focus),
  :global(.hc-focus .sv-grid-cell:focus) {
    outline: 3px solid #fbbf24 !important;
    outline-offset: -2px;
    box-shadow: inset 0 0 0 1px #000 !important;
  }
</style>

View this example on GitHub

More Keyboard & Accessibility 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 + roving tabindex.