Implementation Guide

Vue Accessibility: The Complete WCAG 2.2 Guide

Updated August 2026Reviewed by Khushwant Parihar, CPACC

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 and Svelte accessibility guide.

The WCAG 2.2 Criteria Vue Apps Break Most

WCAG 2.2 success criteria most commonly failed by Vue applications and what each requires
CriterionLevelWhat it requires in Vue
1.3.1 Info & RelationshipsAUse semantic templates; make sure $attrs land on the real control.
2.1.1 KeyboardAInteractive elements must be real buttons/links, not @click divs.
2.1.2 No Keyboard TrapADialogs trap focus deliberately and release it on close.
2.4.3 Focus OrderAMove focus in router.afterEach and on open/close of overlays.
2.4.7 Focus VisibleAAKeep a visible focus outline; never remove it without a replacement.
3.3.1 Error IdentificationATie validation messages to fields with :aria-describedby.
4.1.2 Name, Role, ValueACustom components expose an accessible name, role, and state.
4.1.3 Status MessagesAAAnnounce 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 (h1h2 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 (wraps focus-trap) for dialogs and menus.
  • VueUseuseActiveElement, 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 @click div.
  • 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 tabindex for 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 $attrs to the real control.
  • Images use meaningful alt, or alt="" 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

Common Vue accessibility anti-patterns, why they fail, and the fix
Anti-patternWhy it failsThe 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

  1. Semantic templates. Every clickable thing is a <button> or <a>/<RouterLink>; headings are ordered; one <h1> per view.
  2. Reactive ARIA. Dynamic ARIA uses :aria-* and binds null to remove attributes when off.
  3. Attribute fallthrough. Wrapper components set inheritAttrs: false and v-bind="$attrs" on the real control.
  4. Focus on navigation. router.afterEach moves focus to <main> or the new heading after nextTick().
  5. Overlays. Dialogs use <Teleport>, trap focus, close on Escape, and restore focus.
  6. Live regions. Async results and errors announce from an always-mounted aria-live region (not v-if).
  7. Forms. Labels associated; errors linked with :aria-describedby and :aria-invalid.
  8. 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

Top Pick

Angular Accessibility Guide

Semantic templates, ARIA binding with [attr.aria-*], focus on router navigation, dialogs with cdkTrapFocus, LiveAnnouncer, accessible reactive forms, and testing with @angular-eslint and jasmine-axe
angular accessibility
angular aria
angular focus management
+3 more
View guide
Top Pick

Complete Keyboard Accessibility Guide

Definitive guide to keyboard accessibility with interactive demos, code examples, and testing checklists
keyboard accessibility
focus management
keyboard navigation
View guide
Top Pick

WCAG 4.1.2 Name, Role, Value Guide

Complete guide to name, role, state, and value for UI components: accessible name computation, native HTML vs ARIA, code examples, common failures, and testing
wcag 4.1.2
screen reader
View guide
Top Pick

React Accessibility Guide

Semantic JSX, focus management, accessible modals, ARIA in JSX, live regions, forms with useId, and testing with jest-axe
react accessibility
react aria
react focus management
View guide
Top Pick

WCAG 2.4.7 Focus Visible Guide

Complete guide to visible keyboard focus indicators: why never to remove the outline, :focus-visible, contrast and thickness, forced-colors support, code examples, and testing
keyboard accessibility
focus management
View guide
Top Pick

WCAG 4.1.3 Status Messages Guide

Complete guide to announcing dynamic changes to screen readers without moving focus: ARIA live regions, role=status vs role=alert, toasts, form errors, result counts, code examples, and testing
wcag 4.1.3
live region
View guide