AccessScanRun a free scan

Guide

Vue Accessibility: Building WCAG 2.2 AA Apps That Actually Work for Everyone

Vue gives you reactive components and clean templates, but the framework does nothing to guarantee an accessible result. A single-page app renders <div> soup just as happily as it renders semantic HTML, and the things that break for screen reader and keyboard users (lost focus on navigation, unannounced view changes, custom widgets with no roles) are exactly the things Vue's developer experience hides from you.

This is a concrete walkthrough of Vue accessibility: writing semantic templates, managing focus on route change, building components that expose correct roles and state, avoiding the classic SPA traps, and testing against WCAG 2.2 Level AA, the baseline now required across the EU under the European Accessibility Act.

Start with semantic templates, not div soup

The single highest-leverage move in Vue accessibility is writing real HTML. A <button> is focusable, fires on Enter and Space, and announces its role for free; a <div @click> does none of that. Reach for native elements first: <button> for actions, <a href> for navigation, <nav>, <main>, <header>, <footer> for landmarks, and a single <h1> per view with a logical heading order beneath it.

Component abstraction is where semantics quietly leak away. A <BaseButton> that renders a <div> poisons every screen that uses it. Make the root element configurable and default it to something accessible:

<template><component :is="href ? 'a' : 'button'" :href="href" v-bind="$attrs"><slot /></component></template>

Set inheritAttrs: false so consumer-supplied aria-* and type attributes land on the real interactive element rather than a wrapper. When you genuinely must use a non-semantic element, you owe it role, tabindex="0", and keyboard handlers, a tax native HTML never charges. Every <img> in a template needs an alt; decorative images take alt="" so screen readers skip them.

Focus management on route change

This is the defining SPA accessibility problem, and the one Vue Router will not solve for you. On a server-rendered site, clicking a link loads a new document and the browser moves focus and announces a new page. With <router-view>, the URL changes, the DOM swaps, and focus stays pinned to the link the user just activated, which has often been unmounted. Screen reader users hear nothing; keyboard users are stranded mid-page.

The fix is to deliberately move focus after each navigation. A reliable pattern is to focus the new view's main heading (or a wrapper) on every route change:

router.afterEach(() => { nextTick(() => { const h = document.querySelector('main h1'); if (h) { h.setAttribute('tabindex', '-1'); h.focus(); } }); })

Use tabindex="-1" so the target is programmatically focusable without joining the tab order, and wrap the call in nextTick so the new view has actually mounted. Pair this with a live region (an element with aria-live="polite" whose text you update to the new page title) so the route change is announced even when focus alone is too subtle. Keep a real skip link (<a href="#main">Skip to content</a>) as the first focusable element; it remains the fastest way past your navigation. WCAG 2.2's new 2.4.11 Focus Not Obscured (Minimum, AA) also means a sticky header must never cover the element you just focused.

Browser back and forward deserve care too. Restoring scroll position is good UX, but don't let it fight your focus logic, decide explicitly where focus belongs after a popstate navigation rather than leaving it to chance.

Accessible components: roles, state, and keyboard support

Custom widgets, modals, tabs, comboboxes, disclosure menus, are where most Vue apps fail an audit. The rule of thumb: ARIA describes state, but you write the behavior. A toggle button needs :aria-pressed="isOn"; an accordion trigger needs :aria-expanded bound to its open ref plus aria-controls pointing at the panel id.

A modal dialog is the canonical hard case. To be accessible it must use role="dialog" with aria-modal="true", move focus to the dialog on open, trap Tab and Shift+Tab inside it, close on Escape, and return focus to the element that opened it. Use Vue's <Teleport> to render it at the end of <body> so it escapes any overflow: hidden or stacking-context ancestor, then mark the rest of the page inert while it is open.

  • Bind dynamic ARIA reactively: :aria-expanded, :aria-selected, :aria-pressed, and :aria-current should be driven by refs, not set once.
  • Implement the expected keys: Arrow keys for tabs and menus, Escape to dismiss, Home/End where the ARIA pattern calls for it.
  • Avoid id collisions: a component rendered many times needs unique ids for aria-controls and aria-describedby, generate them with useId().
  • Respect motion: gate transitions behind prefers-reduced-motion so animated route or list changes don't trigger vestibular symptoms.
  • Meet WCAG 2.2 target sizing: interactive targets should be at least 24x24 CSS pixels (2.5.8), and any drag interaction needs a single-pointer alternative (2.5.7).

Forms carry their own checklist that maps directly onto Vue's v-model bindings: every input needs an associated <label>, errors must be programmatically linked with aria-describedby and announced, and aria-invalid should reflect validation state.

SPA pitfalls that automated checks miss

Beyond focus, a handful of traps recur in Vue apps. Async content (data loaded after mount) appears silently; wrap loading and result states in an aria-live region or move focus to the new content so screen reader users know it arrived. v-if versus v-show matters: v-show leaves the element in the DOM with display: none, which correctly hides it from assistive tech, but a half-hidden element left focusable becomes a keyboard trap. Toasts and notifications need role="status" or role="alert" to be announced at all.

WCAG 2.2 added criteria that SPAs frequently miss: 3.2.6 Consistent Help (A) means keeping help in a consistent place across views; 3.3.7 Redundant Entry (A) means not making users re-key data within a flow; and 3.3.8 Accessible Authentication (Minimum, AA) means not forcing users to memorize or transcribe a code where a paste or password manager would do. Color is not a Vue concern but is a constant audit failure: aim for 4.5:1 contrast on normal text and 3:1 on large text (>=18pt, or 14pt bold) and UI component boundaries, and check yours with the contrast checker.

Testing your Vue app for WCAG 2.2 AA

Automated tooling catches a real but limited slice (missing labels, contrast, invalid ARIA) and you should wire it into CI. @axe-core/playwright or vitest-axe lets you assert zero violations on rendered components and key routes. eslint-plugin-vuejs-accessibility catches template-level mistakes (missing alt, click handlers without keyboard handlers) before they merge. Treat these as a floor, not a ceiling: automated tools verify only a fraction of WCAG success criteria.

The criteria that matter most in SPAs (focus order, route announcements, keyboard traps, screen reader output) can only be confirmed by hand. Unplug your mouse and tab through every flow. Run NVDA or VoiceOver and listen to what a route change actually announces. Walk the full accessibility checklist for AA, and run a quick automated pass with AccessScan to triage the easy wins before manual testing.

Check your site against AccessScan

See your issues ranked by impact in seconds — free.

Run a free accessibility scan

FAQ

Does Vue have built-in accessibility features?

Vue renders whatever templates you write, so it neither helps nor hurts accessibility on its own. It offers no automatic focus management on route change, no roles for custom widgets, and no guarantee of semantic output. Accessibility in a Vue app comes entirely from your templates, components, and router configuration.

How do I announce route changes to screen readers in Vue Router?

Use router.afterEach to move focus to the new view's main heading (with tabindex="-1") inside nextTick, and update an aria-live="polite" region with the new page title. Focus alone handles keyboard orientation; the live region ensures the change is also spoken. Keep a skip link as the first focusable element.

Is the European Accessibility Act relevant to my Vue app?

If you sell to consumers in the EU, likely yes. The European Accessibility Act (Directive (EU) 2019/882) has applied since 28 June 2025, and its technical baseline is WCAG 2.2 Level A and AA via the EN 301 549 standard. That is the same bar this guide targets.

Which Vue accessibility testing tools should I use?

Combine eslint-plugin-vuejs-accessibility for template linting, @axe-core/playwright or vitest-axe for automated rule checks in CI, and manual keyboard and screen reader testing (NVDA, VoiceOver) for the focus, announcement, and keyboard-trap issues automation cannot detect. Automated tools alone cover only a fraction of WCAG criteria.

More guides