Svelte Accessibility: The Complete WCAG 2.2 Guide
Svelte is the one framework whose compiler warns you about accessibility as you type. This guide covers the patterns that actually trip Svelte 5 and SvelteKit apps up: reading the a11y_* warnings instead of silencing them, reactive ARIA with runes, focus traps packaged as use: actions, SvelteKit route announcements and focus, live regions that announce, and accessible forms — with copy-ready code and a testing workflow that keeps it accessible.
Why Svelte Accessibility Is Different
Svelte does not make a page inaccessible on its own — it compiles whatever markup you write. Reach for a real <button>, <a>, <nav>, and <label> and you inherit the keyboard behavior, focus handling, and screen reader semantics those elements already provide. What sets Svelte apart from React, Vue, and Angular is that its compiler ships accessibility checks: write an image with no alt or an on:click on a bare <div> and Svelte prints a warning at compile time, before the code runs. No other mainstream framework does this out of the box.
Those warnings are a floor, not a ceiling — they only see static markup. Three things about the Svelte model create accessibility work the compiler cannot check for you. First, client-side routing in SvelteKit swaps the page without a full load; SvelteKit handles more of this than most routers, but focus intent is still yours to refine. Second, reactivity updates the DOM constantly — results, toasts, validation — and none of it is announced unless it happens inside a live region. Third, components hide markup behind reusable pieces, so one wrong choice (a div for a button, a missing label) repeats everywhere it is used.
Get the compiler warnings, reactive ARIA, focus with use:actions, and SvelteKit's navigation behavior right and most of Svelte accessibility falls into place. If you also work in another framework, the same principles map cleanly onto our React accessibility guide, Vue accessibility guide, and Angular accessibility guide.
The WCAG 2.2 Criteria Svelte Apps Break Most
| Criterion | Level | What it requires in Svelte |
|---|---|---|
| 1.3.1 Info & Relationships | A | Use semantic markup; heed a11y_label_has_associated_control. |
| 2.1.1 Keyboard | A | Interactive elements are real buttons/links, not on:click divs. |
| 2.1.2 No Keyboard Trap | A | A use:trapFocus action traps focus deliberately and releases it on close. |
| 2.4.3 Focus Order | A | Refine focus with afterNavigate; open/close overlays move focus. |
| 2.4.7 Focus Visible | AA | Keep a visible focus outline; never remove it without a replacement. |
| 3.3.1 Error Identification | A | Tie validation messages to fields with aria-describedby. |
| 4.1.2 Name, Role, Value | A | Custom components expose an accessible name, role, and state. |
| 4.1.3 Status Messages | AA | Announce async updates in an always-mounted aria-live region. |
For the full list, see the WCAG 2.2 Level AA requirements and the interactive WCAG 2.2 checklist.
1. Write Semantic Markup First
The single highest-impact rule in Svelte accessibility: render the element that already does the job. A <button> is focusable, fires on Enter and Space, and announces its role. A <div on:click> does none of that until you add a role, tabindex, and keyboard handlers by hand — and Svelte will warn you about every one of those omissions as you go.
<!-- Inaccessible: not focusable, no keyboard, no role.
Svelte warns: a11y_no_static_element_interactions
a11y_click_events_have_key_events -->
<div class="btn" on:click={save}>Save</div>
<!-- Accessible: keyboard + role + focus for free -->
<button type="button" on:click={save}>Save</button>
<!-- Navigation is a list of links inside <nav> -->
<nav aria-label="Primary">
<ul>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/guides">Guides</a></li>
</ul>
</nav>Use one <h1> per page and keep headings in order (h1 → h2 → h3) so screen reader users can navigate by heading. Wrap the routed content in <main>, and reach for <button> for actions and <a href> for navigation — in SvelteKit a plain <a> is automatically enhanced into a client-side navigation, so you rarely need a special link component. The difference between a button and a link matters to assistive tech even when they look identical.
2. Read the Compiler's a11y_* Warnings
This is Svelte's signature accessibility advantage. As the compiler analyzes your markup it emits warnings whose codes begin with a11y_ whenever it sees a likely accessibility bug. They print in your terminal during dev and build, and appear inline in your editor through the Svelte extension — so you find the problem while writing the component, not after an audit. No other mainstream framework checks accessibility at compile time.
| Warning code | What it flags |
|---|---|
a11y_missing_attribute | An <img> with no alt, an <a> with no href, and similar required attributes. |
a11y_click_events_have_key_events | An on:click on an element with no keyboard handler. |
a11y_no_static_element_interactions | A handler on a non-interactive element (div, span) that has no role. |
a11y_label_has_associated_control | A <label> not linked to a control via for or by wrapping it. |
a11y_media_has_caption | A <video> with no <track kind="captions">. |
a11y_positive_tabindex | A tabindex greater than zero, which breaks the natural focus order. |
a11y_no_redundant_roles | A role that duplicates an element's implicit one (<button role="button">). |
a11y_role_has_required_aria_props | A role that is missing the ARIA properties it requires. |
Treat every a11y_ warning as a bug to fix, not noise to hide. Svelte lets you suppress a single line with a comment, but that comment is a promise that you have checked the case and it is genuinely a false positive:
<!-- Only after you have verified this specific case is safe.
A suppressed warning is an accessibility defect you keep on purpose. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div role="presentation" on:click={dismiss}>...</div>Run svelte-check in CI so these warnings fail the build instead of scrolling past in a log. The compiler only sees static markup, though — it cannot know whether your dialog traps focus or your toast is announced, which is what the rest of this guide covers. Every role and state the warnings mention is documented in our ARIA roles & attributes reference.
3. Bind ARIA Reactively with Runes
Svelte sets ARIA attributes with an ordinary curly-brace expression in markup, and it stringifies booleans for you — aria-expanded={isOpen} renders aria-expanded="true" or "false". The rule that trips teams up is what Svelte does with empty values: when an expression is null or undefined, Svelte omits the attribute entirely. In Svelte 5 you drive these from runes.
<script lang="ts">
let isOpen = $state(false)
let email = $state("")
// $derived recomputes whenever email changes
let hasError = $derived(email.length > 0 && !email.includes("@"))
</script>
<!-- Boolean state: renders aria-expanded="true" / "false" -->
<button aria-expanded={isOpen} aria-controls="panel" on:click={() => (isOpen = !isOpen)}>
Menu
</button>
<!-- Conditional attribute: present only when there is an error,
omitted entirely when the expression is undefined -->
<input
aria-invalid={hasError || undefined}
aria-describedby={hasError ? "email-error" : undefined}
/>
<!-- Token attributes: bind the token, not a bare boolean -->
<a aria-current={isActive ? "page" : undefined} {href}>Home</a>Bind undefined (or null) when you want an attribute to disappear — binding false renders the literal string aria-invalid="false", which is correct for a true/false ARIA state but wrong for attributes like aria-describedby that should simply be absent. For token attributes such as aria-current, bind the token ("page") rather than a boolean so screen readers announce the right thing. $derived keeps computed ARIA state in sync without a manual watcher.
4. Package Focus Behavior as a use: Action
A Svelte action is a function you attach to an element with use:name. Svelte calls it with the real DOM node when the element mounts and runs the destroy() you return when it unmounts. That lifecycle makes actions the idiomatic Svelte home for accessibility behavior that needs a live element — a focus trap, click-outside-to-close, or moving focus on open. You write the logic once and reuse it with a single attribute.
// actions/trapFocus.ts — a reusable focus trap
export function trapFocus(node: HTMLElement) {
const previouslyFocused = document.activeElement as HTMLElement | null
const selector =
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select, textarea, [tabindex]:not([tabindex="-1"])'
const focusable = () =>
Array.from(node.querySelectorAll<HTMLElement>(selector))
// Move focus into the dialog when it opens
focusable()[0]?.focus()
function onKeydown(e: KeyboardEvent) {
if (e.key !== "Tab") return
const items = focusable()
const first = items[0]
const last = items[items.length - 1]
if (e.shiftKey && document.activeElement === first) {
last.focus()
e.preventDefault()
} else if (!e.shiftKey && document.activeElement === last) {
first.focus()
e.preventDefault()
}
}
node.addEventListener("keydown", onKeydown)
return {
destroy() {
node.removeEventListener("keydown", onKeydown)
previouslyFocused?.focus() // restore focus to what opened the dialog
},
}
}<script lang="ts">
import { trapFocus } from "./actions/trapFocus"
let open = $state(false)
</script>
<button type="button" on:click={() => (open = true)}>Delete project</button>
{#if open}
<div class="backdrop" on:click={() => (open = false)}></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
use:trapFocus
on:keydown={(e) => e.key === "Escape" && (open = false)}
>
<h2 id="dialog-title">Delete this project?</h2>
<p>This action cannot be undone.</p>
<button type="button" on:click={() => (open = false)}>Cancel</button>
<button type="button" on:click={confirm}>Delete</button>
</div>
{/if}Because the action's destroy() runs the moment the {#if} removes the dialog, focus restoration is automatic — no lifecycle bookkeeping in the component. You still supply role="dialog", aria-modal="true", an accessible name via aria-labelledby, and Escape-to-close. For production dialogs, headless libraries such as Bits UI and Melt UI ship a fully accessible Dialog so you rarely hand-roll one. See the accessible modal pattern for the full interaction spec and the focus management guide for traps and restoration in depth.
5. SvelteKit Route Announcements & Focus
Here SvelteKit does more for you than most SPA routers. After each client-side navigation it announces the new page in a visually hidden live region that reads the page's document title, and it resets focus to <body> so keyboard and screen reader users start from the top of the new page (unless an element has autofocus). Two things follow from that.
First, because the announcement reads the title, every route needs a unique, descriptive title. A blank or duplicated title makes the announcement useless. Set it per page with <svelte:head>:
<!-- src/routes/pricing/+page.svelte -->
<svelte:head>
<title>Pricing — Acme</title>
<meta name="description" content="Simple, transparent pricing." />
</svelte:head>Second, focus landing on <body> is safe but blunt. To move it somewhere more useful — the <main> region or the new <h1> — customize it with afterNavigate from $app/navigation in your root layout, and pair it with a skip link:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { afterNavigate } from "$app/navigation"
afterNavigate(() => {
const main = document.getElementById("main")
main?.focus()
})
</script>
<a class="skip-link" href="#main">Skip to main content</a>
<nav aria-label="Primary"><!-- ... --></nav>
<main id="main" tabindex="-1">
<slot />
</main>tabindex="-1" lets <main> receive programmatic focus without adding it to the Tab order. This supports 2.4.3 Focus Order and depends on a working skip link. Because SvelteKit already handles the announcement, resist adding a second one for navigation — you would double-announce every page change.
6. Announce Dynamic Content (and the {#if} Trap)
Reactivity updates the DOM silently. When search results load, a toast appears, or a form saves, a sighted user sees it instantly — a screen reader user hears nothing unless the change happens inside an aria-live region. The catch is the same one every framework hits: a screen reader only announces a live region that already existed in the DOM before its content changed. Mount that region with {#if} at the moment the message appears and nothing is announced.
<!-- Broken: the region is created at the same moment as its text,
so the browser treats it as initial content, not an update -->
{#if message}
<p aria-live="polite">{message}</p>
{/if}
<!-- Correct: the region is always in the DOM; only its text changes -->
<p aria-live="polite" class="sr-only">{message}</p>// lib/announcer.svelte.ts — one persistent region for the whole app
export const announcer = $state({ message: "" })
export function announce(text: string) {
announcer.message = "" // reset so identical messages re-announce
requestAnimationFrame(() => {
announcer.message = text
})
}Render one always-mounted, visually hidden region near the root of your layout (<p aria-live="polite" class="sr-only">{announcer.message}</p>) and call announce() from anywhere. Resetting the text before setting it again forces a re-announcement even when the new message is identical to the last. Reserve aria-live="assertive" for urgent, interrupting messages such as a session-timeout warning. This satisfies 4.1.3 Status Messages.
The Svelte accessibility toolkit
Svelte does the first layer itself in the compiler; a small, well-supported ecosystem covers the rest:
- The Svelte compiler — built-in
a11y_*warnings, no configuration required. svelte-check— runs the compiler diagnostics (including a11y warnings) in CI so they fail the build.eslint-plugin-svelte— adds further static lint rules on top of the compiler.- Bits UI / Melt UI — unstyled, accessible dialog, menu, combobox, tabs, and listbox primitives with keyboard and ARIA built in.
@testing-library/svelte+vitest-axe— component tests that assert against the accessibility tree.
7. Accessible Forms with bind:value
bind:value handles the data binding, but says nothing about accessibility. Associate every input with a <label> (the compiler's a11y_label_has_associated_control warning enforces this), then link the error message with aria-describedby and mark the field with aria-invalid — binding undefined so the attributes disappear when the field is valid.
<script lang="ts">
let email = $state("")
let touched = $state(false)
let invalid = $derived(touched && !/^[^@]+@[^@]+\.[^@]+$/.test(email))
</script>
<form on:submit|preventDefault={submit}>
<label for="email">Email</label>
<input
id="email"
type="email"
bind:value={email}
on:blur={() => (touched = true)}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "email-error" : undefined}
/>
{#if invalid}
<p id="email-error" class="error">Enter a valid email address.</p>
{/if}
<button type="submit">Create account</button>
</form>Never rely on a placeholder as the label — it disappears on input and usually fails contrast. Only surface aria-invalid and aria-describedby once the user has touched the field (here, on blur), so assistive tech is not told about an error before it is shown. The error text sits inside a {#if} — that is fine, because aria-describedby resolves the id when it exists; it is only live regions that must stay mounted. For labels, <fieldset> grouping, validation timing, and error summaries, see the accessible forms guide and the form validation & error handling guide.
Keyboard Rules for Svelte
- Interactive = real
<button>or<a>, never anon:clickdiv. - Refine focus on navigation with
afterNavigate; move it on overlay open/close. - Trap focus in dialogs with a
use:trapFocusaction; restore it on close. - Keep a visible focus outline (2.4.7).
- Roving
tabindexfor arrow-key widgets (tabs, menus, listboxes).
See the keyboard accessibility guide.
Screen Reader Rules
- Give every route a unique
<svelte:head><title>— SvelteKit announces it. - Announce async updates via an always-mounted live region.
- Every control has an accessible name (label or
aria-label). - Icon-only buttons need a name; decorative icons get
aria-hidden="true". - Images use meaningful
alt, oralt=""if decorative.
Test with real AT — the screen reader testing guide.
8. Testing & Tooling
Automated checks catch a meaningful share of issues and stop regressions — but they find roughly a third to a half of WCAG problems, so they supplement rather than replace manual testing. Svelte gives you an extra automated layer for free in the compiler; layer these into your workflow:
# 1. The compiler + svelte-check — a11y warnings fail the build
svelte-check --fail-on-warnings
# 2. Lint on top of the compiler — eslint-plugin-svelte
# In eslint.config.js, extend the svelte recommended config.// 3. Component tests: @testing-library/svelte + vitest-axe
import { render } from "@testing-library/svelte"
import { axe } from "vitest-axe"
import TextField from "./TextField.svelte"
it("TextField has no axe violations", async () => {
const { container } = render(TextField, { props: { label: "Email", id: "email" } })
expect(await axe(container)).toHaveNoViolations()
})
// 4. End-to-end: axe-core in Playwright against real routes
import AxeBuilder from "@axe-core/playwright"
test("home page is accessible", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
})Svelte Testing Library nudges you toward accessible queries — getByRole and getByLabelText only pass when the accessibility tree is correct, so writing tests this way surfaces missing names early. Finish every feature with a manual keyboard pass and a screen reader pass. Read our comparison of automated vs manual testing to see where each fits.
Common Svelte Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
<div on:click={...}> used as a button. | Not focusable, no keyboard, no role — and the compiler already warned you (WCAG 2.1.1, 4.1.2). | Use a real <button>. Fix a11y_click_events_have_key_events; don't svelte-ignore it. |
Suppressing an a11y_ warning to ship faster. | A silenced warning is a real defect kept on purpose (varies by rule). | Fix the markup. Reserve <!-- svelte-ignore --> for documented, verified exceptions. |
Live region added with {#if} when the message appears. | The region didn't exist before the change, so nothing is announced (4.1.3). | Keep an always-mounted aria-live region and only change its text. |
Routes without a unique <svelte:head><title>. | SvelteKit's navigation announcement reads the title — a blank or duplicate title says nothing useful (2.4.3). | Set a descriptive per-page title in svelte:head. |
Hand-rolled dialog with no focus trap or restoration. | Focus escapes behind the overlay and never returns (2.1.2, 2.4.3). | Package the trap as a use:trapFocus action, or use a headless library (Bits UI / Melt UI). |
Form errors not tied to their input. | Screen reader users hear the field but not why it failed (3.3.1). | Bind aria-invalid and aria-describedby to the message, undefined when valid. |
Svelte Accessibility Checklist
- Zero a11y warnings. The build has no unresolved
a11y_*warnings;svelte-checkruns in CI and anysvelte-ignoreis documented. - Semantic markup. Every clickable thing is a
<button>or<a href>; headings ordered; one<h1>per page. - Reactive ARIA. Dynamic ARIA uses
{expression}bindings andundefinedto remove attributes when off. - Focus behavior. Traps and restoration are packaged as
use:actions; overlays move focus in and back out. - Route titles & focus. Every route sets a unique
<svelte:head><title>;afterNavigaterefines focus; a skip link exists. - Live regions. Async results and errors announce from an always-mounted
aria-liveregion (not{#if}). - Forms. Labels associated; errors linked with
aria-describedbyandaria-invalid. - Automated + manual. Compiler + eslint-plugin-svelte + vitest-axe in CI, plus a keyboard and screen reader pass.
Scan the deployed build with our URL accessibility auditor and work through the full WCAG 2.2 checklist.
Audit Your Svelte App in Seconds
Run any deployed Svelte or SvelteKit page through our free axe-core-powered auditor to catch missing names, unlabeled controls, and contrast failures — then work through the manual checks above.
Frequently Asked Questions
Is Svelte accessible by default?▾
Svelte renders whatever markup you write, so if you reach for semantic elements (button, a, nav, label, input, h1–h6) you inherit their keyboard behavior and screen reader semantics for free. Svelte goes one step further than most frameworks: its compiler ships built-in accessibility checks and prints a11y warnings at compile time when it spots problems like an image without alt text, a click handler on a non-interactive div, or a label with no associated control. Those warnings catch a meaningful slice of issues before the code ever runs — but they only see static markup, so dynamic ARIA, focus management on navigation, and live-region announcements are still on you. Svelte can be fully WCAG 2.2 AA accessible; it is something you build in, not something you get for free.
What are Svelte's a11y warnings and should I disable them?▾
When the Svelte compiler analyzes your markup it emits warnings whose codes start with a11y_ — for example a11y_missing_attribute (an img with no alt), a11y_click_events_have_key_events (an on:click element with no keyboard handler), a11y_no_static_element_interactions (a handler on a div or span that has no role), a11y_label_has_associated_control, and a11y_media_has_caption. They show in your terminal during dev and build and in your editor through the Svelte extension. Treat them as bugs to fix, not noise to silence. You can suppress a single line with a <!-- svelte-ignore a11y_… --> comment, but only do that when you have a documented, verified reason — a suppressed warning is an accessibility defect you have chosen to keep. Never disable the whole category.
How do I bind ARIA attributes reactively in Svelte?▾
Write the attribute directly in markup with a curly-brace expression: aria-expanded={isOpen} renders aria-expanded="true" or "false" because Svelte stringifies the boolean. The key rule is what happens with empty values: when the expression is null or undefined, Svelte omits the attribute entirely. That lets you write aria-describedby={hasError ? 'email-error' : undefined} so the attribute only appears when there is genuinely an error. For token attributes bind the token, not a bare boolean — aria-current={isActive ? 'page' : undefined}, not aria-current={isActive}, which renders the less useful aria-current="true". In Svelte 5, drive these from runes such as $state and $derived.
How do I package focus management as a Svelte action?▾
A Svelte action is a function you attach to an element with use:name. The compiler calls it with the DOM node when the element mounts, and you return an object with an optional destroy() that runs on unmount. That lifecycle makes actions the idiomatic Svelte home for accessibility behavior that needs a real element — a focus trap, click-outside-to-close, or moving focus on mount. You write use:trapFocus on your dialog once, and the action owns adding the keydown listener, cycling Tab and Shift+Tab inside the dialog, and cleaning the listener up on close. It keeps the behavior reusable and testable instead of scattered across component lifecycles.
Does SvelteKit handle focus and announcements on navigation?▾
More than most SPA routers. After each client-side navigation SvelteKit announces the new page in a visually hidden live region that reads the page's document title, and it resets focus to the body element so keyboard and screen reader users start from the top of the new page (unless an element on the page has autofocus). Two things follow: give every route a unique, descriptive title via svelte:head, because that title is literally what gets announced; and if you want focus to land somewhere more useful than body — the main region or the new h1 — customize it with afterNavigate from $app/navigation. Pair that with a skip link. This supports WCAG 2.4.3 Focus Order.
How do I test a Svelte app for accessibility?▾
Use four layers. First, the compiler: its a11y warnings run every build, and svelte-check surfaces them in CI so they fail the pipeline. Second, lint: eslint-plugin-svelte adds further static rules on top of the compiler. Third, component tests: render components with @testing-library/svelte — which pushes you toward accessible queries like getByRole and getByLabelText — and assert with vitest-axe (or jest-axe) using the toHaveNoViolations matcher. Fourth, end-to-end: run axe-core through @axe-core/playwright against real routes. None of these replace a manual keyboard and screen reader pass, which is the only way to confirm the experience actually works.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences