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-currentshould 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
idcollisions: a component rendered many times needs unique ids foraria-controlsandaria-describedby, generate them withuseId(). - Respect motion: gate transitions behind
prefers-reduced-motionso 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.