Vue Accessibility: The Complete WCAG 2.2 Guide
Vue is accessible when you build it that way. This guide covers the patterns that actually trip Vue 3 apps up: semantic templates, reactive :aria-* binding, focus on Vue Router navigation, dialogs with <Teleport>, live regions that actually announce, and the Vue-only pitfall of $attrs fallthrough — with copy-ready <script setup> code and a testing workflow that keeps it accessible.
Why Vue Accessibility Is Different
Vue does not make a page inaccessible on its own — it renders whatever elements you put in the template. 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. The trouble is that Vue makes it just as easy to bind a @click handler to a <div> that looks like a button but is invisible to assistive technology.
Three things about the Vue model create accessibility work you would not have on a static site. First, client-side routing swaps the routed component without a full page load, so focus and screen reader context are never reset unless you do it. Second, reactivity updates the DOM constantly — results, toasts, validation — and none of it is announced unless it happens inside a live region. Third, single-file components hide markup behind reusable pieces, so one wrong choice (a div for a button, a missing label) repeats everywhere it is used.
Vue also has two behaviors that matter more for accessibility than any others: attribute fallthrough (where an aria-label passed to a component quietly lands on the wrong element) and the difference between v-if and v-show for live regions. Get those, the routing focus, and reactive ARIA binding right and most of Vue accessibility falls into place. If you also work in React or Angular, the same principles map cleanly onto our React accessibility guide and Angular accessibility guide.
The WCAG 2.2 Criteria Vue Apps Break Most
| Criterion | Level | What it requires in Vue |
|---|---|---|
| 1.3.1 Info & Relationships | A | Use semantic templates; make sure $attrs land on the real control. |
| 2.1.1 Keyboard | A | Interactive elements must be real buttons/links, not @click divs. |
| 2.1.2 No Keyboard Trap | A | Dialogs trap focus deliberately and release it on close. |
| 2.4.3 Focus Order | A | Move focus in router.afterEach and on open/close of overlays. |
| 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 Templates First
The single highest-impact rule in Vue accessibility: render the element that already does the job. A <button> is focusable, fires on Enter and Space, and announces its role. A <div @click> does none of that until you add a role, tabindex, and keyboard handlers by hand — and get all three exactly right.
<!-- Inaccessible: not focusable, no keyboard, no role -->
<div class="btn" @click="save">Save</div>
<!-- Accessible: keyboard + role + focus for free -->
<button type="button" @click="save">Save</button>
<!-- Navigation is a list of links inside <nav> -->
<nav aria-label="Primary">
<ul>
<li><RouterLink to="/pricing">Pricing</RouterLink></li>
<li><RouterLink to="/guides">Guides</RouterLink></li>
</ul>
</nav>Use one <h1> per view 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 <RouterLink> (which renders a real <a>) for navigation — the difference matters to assistive tech even when they look identical. The vuejs-accessibility/click-events-have-key-events rule catches most of these as you type.
2. Bind ARIA Reactively with :aria-*
Vue's v-bind (the : shorthand) sets ARIA attributes directly, and it stringifies booleans for you — :aria-expanded="isOpen" renders aria-expanded="true" or "false". The rule that trips teams up is what Vue does with empty values: when a bound expression is null or undefined, Vue removes the attribute entirely. That is exactly what you want for conditional states.
<!-- Boolean state: renders aria-expanded="true" / "false" -->
<button :aria-expanded="isOpen" :aria-controls="panelId">Menu</button>
<!-- Conditional attribute: present only when there is an error,
removed entirely when the expression is null -->
<input :aria-invalid="hasError || null" :aria-describedby="hasError ? 'err' : null" />
<!-- Token attributes: bind the token, not a bare boolean -->
<a :aria-current="isActive ? 'page' : null" :href="href">Home</a>
<!-- Many ARIA attributes at once with the object form of v-bind -->
<div v-bind="{ role, 'aria-expanded': open, 'aria-controls': panelId }" />Bind null (not false) 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. Every role and state is documented in our ARIA roles & attributes reference.
3. Fix Attribute Fallthrough on Wrapper Components
This is the Vue-specific bug that quietly breaks accessible names. When a parent passes aria-label, id, or aria-describedbyto your custom component, Vue applies those attributes to the component's single root element by default. If your component wraps an <input> inside a <div>, the aria-label lands on the wrapper — where it does nothing — and the input stays unlabeled.
<!-- TextField.vue -->
<script setup lang="ts">
// Stop attributes from landing on the root <div>...
defineOptions({ inheritAttrs: false })
defineProps<{ label: string; id: string }>()
const model = defineModel<string>()
</script>
<template>
<div class="field">
<label :for="id">{{ label }}</label>
<!-- ...forward them onto the real control instead -->
<input :id="id" v-model="model" v-bind="$attrs" />
</div>
</template>Setting inheritAttrs: false tells Vue not to apply fallthrough attributes to the root element, and v-bind="$attrs" forwards them onto the <input>, so a parent's aria-describedby, aria-invalid, or event listeners reach the element that actually needs them. Any time a reusable component wraps a native control in extra markup, this pattern is what keeps its accessible name and state intact (WCAG 4.1.2 Name, Role, Value).
4. Manage Focus on Vue Router Navigation
When Vue Router changes routes, the browser does not reset focus the way a full page load would. Keyboard and screen reader users are left on whatever link they activated, now pointing at content that no longer exists. Move focus to the top of the new view in router.afterEach() — after a nextTick() so the new component has actually rendered.
// router/index.ts
import { createRouter, createWebHistory } from "vue-router"
import { nextTick } from "vue"
const router = createRouter({
history: createWebHistory(),
routes: [/* ... */],
})
router.afterEach(async () => {
await nextTick()
const main = document.querySelector<HTMLElement>("main")
main?.focus()
})
export default router<!-- App.vue -->
<template>
<a class="skip-link" href="#main">Skip to main content</a>
<nav aria-label="Primary"><!-- ... --></nav>
<main id="main" tabindex="-1">
<RouterView />
</main>
</template>tabindex="-1" lets <main> receive programmatic focus without adding it to the Tab order. Prefer focusing a container or the new <h1> over announcing the whole page. This satisfies 2.4.3 Focus Order and depends on a working skip link. For the full picture of programmatic focus, traps, and restoration, see the focus management guide.
5. Accessible Dialogs with <Teleport>& Focus Trapping
A dialog is the classic Vue focus challenge. When it opens, focus must move into it; while open, focus must stay trapped inside; on close, focus must return to the control that opened it. Vue's <Teleport> moves the dialog markup to <body> so it escapes any overflow or stacking context, and a focus-trap library keeps keyboard focus inside while it is open.
<script setup lang="ts">
import { ref, watch, nextTick } from "vue"
import { useFocusTrap } from "@vueuse/integrations/useFocusTrap"
const open = ref(false)
const dialog = ref<HTMLElement | null>(null)
const opener = ref<HTMLElement | null>(null)
const { activate, deactivate } = useFocusTrap(dialog)
watch(open, async (isOpen) => {
if (isOpen) {
opener.value = document.activeElement as HTMLElement
await nextTick()
activate() // move + trap focus inside the dialog
} else {
deactivate()
opener.value?.focus() // restore focus to what opened it
}
})
</script>
<template>
<button type="button" @click="open = true">Delete project</button>
<Teleport to="body">
<div v-if="open" class="backdrop" @click="open = false" />
<div
v-if="open"
ref="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
@keydown.esc="open = false"
>
<h2 id="dialog-title">Delete this project?</h2>
<p>This action cannot be undone.</p>
<button type="button" @click="open = false">Cancel</button>
<button type="button" @click="confirm">Delete</button>
</div>
</Teleport>
</template>You add role="dialog", aria-modal="true", an accessible name via aria-labelledby, and Escape-to-close yourself. For production dialogs, headless libraries like Headless UI Vue and Reka UI ship a Dialog that handles the Teleport, focus trap, focus restoration, and aria-modal for you — so you rarely need to hand-roll one. See the accessible modal pattern for the full interaction spec.
6. Announce Dynamic Content (and the v-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 Vue-specific catch: a screen reader only announces a live region that already existed in the DOM before its content changed. Mount that region with v-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 -->
<p v-if="message" aria-live="polite">{{ message }}</p>
<!-- Correct: the region is always in the DOM; only its text changes -->
<p aria-live="polite" class="sr-only">{{ message }}</p>// A reusable announcer composable — one persistent region for the app
// composables/useAnnouncer.ts
import { ref } from "vue"
const message = ref("")
export function useAnnouncer() {
function announce(text: string) {
message.value = "" // reset so identical messages re-announce
requestAnimationFrame(() => { message.value = text })
}
return { message, announce }
}Render one always-mounted, visually hidden region near the root (<p aria-live="polite" class="sr-only">{{ message }}</p>) and drive it from the composable. Use v-show rather than v-if if you must toggle visibility, since it keeps the element in the DOM. Reserve aria-live="assertive" for urgent, interrupting messages such as a session-timeout warning. This satisfies 4.1.3 Status Messages.
The Vue accessibility toolkit
Vue does not ship a first-party accessibility package the way Angular's CDK does, but a small, well-supported ecosystem covers the same ground:
eslint-plugin-vuejs-accessibility— lint rules that catch@click-without-keyboard, missing labels, and invalid ARIA in templates.- Headless UI Vue / Reka UI — unstyled, accessible dialog, menu, combobox, tabs, and listbox components with keyboard and ARIA built in.
@vueuse/integrations/useFocusTrap— a focus trap composable (wrapsfocus-trap) for dialogs and menus.- VueUse —
useActiveElement,onKeyStroke, and other primitives useful for focus and keyboard handling. vue-axe— surfaces axe-core violations live in the console during development.
7. Accessible Forms with v-model
v-model handles the data binding, but says nothing about accessibility. Associate every input with a <label>, then link the error message with :aria-describedby and mark the field with :aria-invalid — binding null so the attributes disappear when the field is valid.
<script setup lang="ts">
import { ref, computed } from "vue"
const email = ref("")
const touched = ref(false)
const emailInvalid = computed(
() => touched.value && !/^[^@]+@[^@]+\.[^@]+$/.test(email.value)
)
</script>
<template>
<form @submit.prevent="submit">
<label for="email">Email</label>
<input
id="email"
type="email"
v-model="email"
@blur="touched = true"
:aria-invalid="emailInvalid || null"
:aria-describedby="emailInvalid ? 'email-error' : null"
/>
<p v-if="emailInvalid" id="email-error" class="error">
Enter a valid email address.
</p>
<button type="submit">Create account</button>
</form>
</template>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 message text sits inside a v-if — that is fine, because aria-describedby resolves the id when it exists; it is only live regions that must stay mounted. For labels, grouping with <fieldset>, validation, and error summaries, see the accessible forms guide and 3.3.1 Error Identification.
Keyboard Rules for Vue
- Interactive = real
<button>or<a>, never a@clickdiv. - Move focus on router navigation and on overlay open/close.
- Trap focus in dialogs (
useFocusTrap); 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
- Announce navigation and 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". - Wrapper components forward
$attrsto the real control. - 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. Layer three tools into your Vue workflow:
// 1. Lint templates — eslint-plugin-vuejs-accessibility
// In eslint.config.js, extend "plugin:vuejs-accessibility/recommended".
// Key rules:
// "vuejs-accessibility/click-events-have-key-events"
// "vuejs-accessibility/form-control-has-label"
// "vuejs-accessibility/anchor-has-content"
// "vuejs-accessibility/aria-props"
// "vuejs-accessibility/alt-text"
// 2. Component tests: @testing-library/vue + vitest-axe
import { render } from "@testing-library/vue"
import { axe } from "vitest-axe"
import TextField from "./TextField.vue"
it("TextField has no axe violations", async () => {
const { container } = render(TextField, {
props: { label: "Email", id: "email" },
})
expect(await axe(container)).toHaveNoViolations()
})
// 3. 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([])
})Vue 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 and broken $attrs forwarding 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 Vue Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
<div @click="..."> used as a button. | Not focusable, not keyboard-operable, no role announced (WCAG 2.1.1, 4.1.2). | Use a real <button>. vuejs-accessibility/click-events-have-key-events flags this. |
aria-label passed to a wrapper component. | Fallthrough puts it on the root div, not the inner input — no accessible name (1.3.1, 4.1.2). | Set inheritAttrs: false and v-bind="$attrs" on the <input>. |
Live region mounted with v-if when the message appears. | The region didn't exist before the change, so nothing is announced (4.1.3). | Use v-show or keep an always-mounted aria-live region and update its text. |
Router navigation that never moves focus. | Keyboard and screen reader users are stranded on the old view (2.4.3). | Focus <main> or the new <h1> in router.afterEach after nextTick(). |
Custom overlay without a focus trap. | Focus escapes behind the dialog; users get lost (2.1.2, 2.4.3). | Trap focus (focus-trap / Headless UI Vue) and Teleport the dialog to <body>. |
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, null when valid. |
Vue Accessibility Checklist
- Semantic templates. Every clickable thing is a
<button>or<a>/<RouterLink>; headings are ordered; one<h1>per view. - Reactive ARIA. Dynamic ARIA uses
:aria-*and bindsnullto remove attributes when off. - Attribute fallthrough. Wrapper components set
inheritAttrs: falseandv-bind="$attrs"on the real control. - Focus on navigation.
router.afterEachmoves focus to<main>or the new heading afternextTick(). - Overlays. Dialogs use
<Teleport>, trap focus, close on Escape, and restore focus. - Live regions. Async results and errors announce from an always-mounted
aria-liveregion (notv-if). - Forms. Labels associated; errors linked with
:aria-describedbyand:aria-invalid. - Automated + manual. eslint-plugin-vuejs-accessibility + 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 Vue App in Seconds
Run any deployed Vue or Nuxt 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 Vue accessible by default?▾
Vue is neutral — it renders whatever you write in your template. If you use semantic elements (button, a, nav, label, input, h1–h6), your app inherits the keyboard behavior and screen reader semantics those elements already ship with. Accessibility problems come from Vue patterns, not the framework: div elements with @click handlers, router navigation that never moves focus, reactivity that updates the DOM without announcing it, and custom components that reimplement native controls without keyboard support. Vue can be fully WCAG 2.2 AA accessible — and the official eslint-plugin-vuejs-accessibility catches many issues as you type — but accessibility is something you build in, not something you get for free.
How do I bind ARIA attributes reactively in Vue?▾
Use v-bind (the : shorthand): :aria-expanded="isOpen" renders aria-expanded="true" or "false" because Vue stringifies the boolean for you. The key Vue rule is what happens with empty values: when a bound expression is null or undefined, Vue removes the attribute entirely. That lets you write :aria-describedby="hasError ? 'email-error' : null" so the attribute only appears when there is genuinely an error, instead of shipping aria-describedby pointing at nothing. For token attributes, bind the token, not a boolean — :aria-current="isActive ? 'page' : null", not :aria-current="isActive", which would render the less useful aria-current="true". Static ARIA values that never change can stay as plain attributes.
What is attribute fallthrough and why does it break accessibility?▾
When a parent passes aria-label, id, or aria-describedby to your custom component, Vue's fallthrough behavior applies those attributes to the component's single root element by default. If your component wraps an <input> inside a <div>, the aria-label lands on the wrapper div — where it does nothing — instead of on the input, so screen readers announce an unlabeled field. The fix is to set inheritAttrs: false (via defineOptions in <script setup>) and then bind the attributes onto the correct inner element with v-bind="$attrs". This is the single most common Vue-specific accessibility bug in reusable form components.
Should I use v-if or v-show for an ARIA live region?▾
Use v-show, or keep the region always mounted. A screen reader only announces changes inside a live region if that region already existed in the accessibility tree before its text changed. v-if adds and removes the element from the DOM, so a live region created by v-if at the moment your message appears announces nothing — the browser sees the region and its content arrive together and treats it as initial content, not an update. v-show keeps the element in the DOM and just toggles display, so live announcements fire. The most reliable pattern is a permanently mounted, visually hidden <div aria-live="polite"> whose text you update reactively. This satisfies WCAG 4.1.3 Status Messages.
How do I move focus when the route changes in a Vue app?▾
Vue Router swaps the routed component without a full page load, so the browser never resets focus the way a traditional navigation would. Keyboard and screen reader users are left on the link they activated, now pointing at content that no longer exists. Fix it with router.afterEach(): wait a tick with nextTick() so the new view has rendered, then move focus to the top of the page — typically a <main> element or the new <h1> given tabindex="-1" so it can receive programmatic focus. Pair that with a skip link. This satisfies WCAG 2.4.3 Focus Order.
How do I test a Vue app for accessibility?▾
Use three layers. First, lint the templates: eslint-plugin-vuejs-accessibility ships rules such as click-events-have-key-events, form-control-has-label, anchor-has-content, and aria-props that catch many issues as you type. Second, component tests: render components with @testing-library/vue — which encourages accessible queries like getByRole and getByLabelText — and assert with vitest-axe (or jest-axe) using the toHaveNoViolations matcher. Third, end-to-end: run axe-core through @axe-core/playwright against real routes. None of these replace manual keyboard and screen reader testing, 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