Accessible Data Tables in Svelte
Permalink to "Accessible Data Tables in Svelte"Svelte compiles components to plain DOM operations, which makes it a good fit for accessible data tables: you write ordinary HTML, and the compiler warns about many accessibility mistakes at build time (missing alt text, click handlers on non-interactive elements, invalid ARIA). Svelte also has no large table library ecosystem compared with React, so teams often build their own tables — which is a chance to get the semantics right from the start.
This guide belongs to web components & framework-agnostic grids.
Spec reference
Permalink to "Spec reference"- Semantic table with
caption,th scope="col",th scope="row". - Sort pattern —
buttoninth;aria-sortonth. - SC 4.1.3 (AA) — status region present from first render.
- Svelte a11y warnings — compile-time checks such as
a11y_click_events_have_key_events,a11y_no_noninteractive_element_interactions,a11y_role_has_required_aria_props(names vary by version). - SvelteKit — client-side navigation; SvelteKit announces route changes by default with its route announcer, based on the page title.
When to use — and when not to
Permalink to "When to use — and when not to"Use a hand-built semantic table in Svelte for most data tables; add a headless library (for example, TanStack Table’s Svelte adapter) for complex sorting, filtering and pagination logic, while keeping the markup yours.
Do not silence Svelte’s accessibility warnings with ignore comments to get a clean build; each warning points to a real barrier. The misapplication: making rows clickable with on:click on tr and suppressing the warning, leaving keyboard users unable to open records.
Annotated code example
Permalink to "Annotated code example"<script>
let { rows, label = 'Invoices' } = $props();
let sort = $state({ key: 'due', dir: 'ascending' });
let status = $state('');
const sorted = $derived([...rows].sort(compare(sort)));
function toggle(key, name) {
sort = { key, dir: sort.key === key && sort.dir === 'ascending' ? 'descending' : 'ascending' };
status = `Sorted by ${name}, ${sort.dir}`; // SC 4.1.3
}
</script>
<table>
<caption>{label}</caption> <!-- name (SC 1.3.1) -->
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col" aria-sort={sort.key === 'customer' ? sort.dir : 'none'}>
<button type="button" onclick={() => toggle('customer', 'Customer')}>Customer</button>
</th>
<th scope="col" aria-sort={sort.key === 'due' ? sort.dir : 'none'}>
<button type="button" onclick={() => toggle('due', 'Due date')}>Due date</button>
</th>
</tr>
</thead>
<tbody>
{#each sorted as row (row.id)} <!-- keyed: stable DOM and focus -->
<tr>
<th scope="row"><a href={`/invoices/${row.id}`}>{row.id}</a></th>
<td>{row.customer}</td>
<td><time datetime={row.due}>{fmt(row.due)}</time></td>
</tr>
{/each}
</tbody>
</table>
<p role="status" class="visually-hidden">{status}</p> <!-- rendered from the start -->
The example uses Svelte 5 runes; the same structure works with Svelte 4 stores and on:click.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Interaction | Expected | Announcement |
|---|---|---|
| T (table) | Reaches table | “Invoices, table…” |
| Table navigation | Headers with cells | Native |
| Tab to header button | Focus | Header name |
| Enter | Sort | aria-sort and “Sorted by Customer, ascending” |
| Tab to row link | Focus | “INV-1042, link” |
| SvelteKit navigation | New page | Route announcer reads the title |
Integration context
Permalink to "Integration context"Framework-agnostic sorting and announcements are covered in sortable & filterable data grids. Route changes in SvelteKit relate to managing route change focus in Angular and Vue, whose principles apply. For a reusable web component instead, see building an accessible data table in Lit.
Gotchas
Permalink to "Gotchas"Unkeyed each blocks. Without keys, Svelte reuses DOM nodes by index; after sorting, a focused link may now point at a different record. Always key rows by ID.
Transitions. Svelte transitions and animations on rows should respect prefers-reduced-motion.
SvelteKit route announcer. It announces the page title; make titles unique and descriptive with <svelte:head><title>.
Worked example
Permalink to "Worked example"A SvelteKit analytics app had clickable table rows with on:click on tr and the related warning suppressed. Keyboard users could not open reports, and screen reader users heard rows with no indication they were interactive. The team removed the suppression, moved the action to a link in the row header cell, keyed each blocks by report ID, added a persistent status region for sorting and filtering, and set unique page titles so SvelteKit’s route announcer named each report. The build now fails on any accessibility warning.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"- Build with no accessibility warnings.
- Press T and confirm the caption names the table.
- Navigate cells with table commands.
- Sort by keyboard; confirm aria-sort and the message.
- Sort while a row link is focused; confirm focus stays on the same record.
- Navigate to a record and back in SvelteKit; confirm title announcement and focus.
Common mistakes
Permalink to "Common mistakes"- Suppressing a11y warnings.
- Clickable rows instead of links or buttons.
- Unkeyed each blocks.
- Status regions inside
{#if}blocks.
Adoption checklist for teams
Permalink to "Adoption checklist for teams"- Treat Svelte a11y warnings as errors in CI.
- Provide a table component with caption, sort headers and status region.
- Key every each block by record ID.
- Use links or buttons for row actions.
- Set unique page titles in SvelteKit.
FAQ
Permalink to "FAQ"Does Svelte make components accessible automatically?
No, but its compile-time warnings catch many common mistakes early.
Is there a Svelte table library?
Headless options like TanStack Table’s Svelte adapter handle logic; keep the markup semantic yourself.
Why must the status region be outside an if block?
Regions created at the same time as their text are often not announced. Render it always.
How do I handle row selection?
Checkboxes in a first column with per-row labels, and a count message on change.
Does SvelteKit handle focus on navigation?
It resets focus to the body by default and announces the title; move focus to the main heading if your design benefits.
Can I compile Svelte components to web components?
Yes, with customElement mode; the shadow DOM considerations for relationships then apply.
How do I test?
Use Playwright with role queries and keyboard events; Svelte’s testing library helpers support role-based queries.
Can I use TanStack Table with Svelte accessibly?
Yes. Let TanStack handle sorting and filtering state and render semantic markup yourself, with aria-sort from the column’s sort state.
How do I announce filter results in Svelte?
Update the status text from an effect or reactive statement after the filtered rows change, debounced for typing.
Do Svelte transitions affect screen readers?
Transitions can delay when elements appear or disappear in the DOM; keep them short and respect reduced motion so focus and announcements are not affected.
Related
Permalink to "Related"- Building a data table in Lit — a web component alternative
- Building an accessible sortable table in React — the React version
- Restoring focus and scroll on Back — list positions