Vue.js Accessibility Guide 2026: WCAG Compliance for Vue & Nuxt Apps
Vue's template syntax and reactivity system make accessible UIs achievable — but they don't guarantee it. Single-file components hide semantic structure, Vue Router's client-side navigation breaks focus management, and the Composition API's flexibility means accessibility patterns vary wildly between codebases. This guide shows you exactly where Vue apps fail WCAG 2.1 AA and how to fix them.
Quick Summary
- Vue templates are just HTML — accessibility mistakes come from patterns, not the framework itself
- Vue Router navigation doesn't announce page changes to screen readers by default
- vue-axe catches ~30–40% of WCAG issues at dev time; supplement with manual testing
- Headless UI for Vue and Radix Vue handle complex ARIA patterns (dialogs, menus) correctly
- Nuxt 3 has built-in
useHeadfor page titles — always update them on navigation
The Most Common Vue Accessibility Mistakes
Vue accessibility bugs cluster into predictable patterns. Fix them in your shared components and you fix them everywhere.
@click on a <div> or <span> creates a mouse-only interaction. Keyboard users and screen reader users are excluded. Use <button> for actions and <RouterLink>/<a> for navigation. If you must attach @click to a div, also add tabindex="0", role="button", and @keydown.enter.space handlers — but <button> is almost always correct.
Vue Router doesn't move focus or announce route changes. Screen reader users tab through a page and have no way to know new content loaded. Add a router.afterEach hook that sets focus to the main heading or a visually hidden skip target, and update the document title on every route change.
v-show toggles CSS display:none, which hides content from screen readers correctly. v-if removes the DOM node entirely — but if you're toggling interactive content (like a dropdown menu), use v-show with aria-expanded and aria-controls so the relationship between trigger and panel is announced. Don't use both v-show and aria-hidden on the same element — pick one.
When reactive data changes — search results updating, form validation firing, notifications appearing — screen readers don't announce it unless you use ARIA live regions. Add aria-live="polite" to containers that receive dynamic content. For urgent alerts (errors that block form submission), use aria-live="assertive" or role="alert".
placeholder is not a label. Every input needs an associated <label> element with a matching for/id pair, or aria-label/aria-labelledby attributes. In Vue, computed IDs work well: :id="`field-${uid}`" with :for="`field-${uid}`" on the label. Don't rely on placeholder text — it disappears when users type and doesn't meet WCAG 1.3.1.
Custom Vue components that visually mimic <select> menus or comboboxes need the full ARIA combobox or listbox pattern: role="combobox", aria-expanded, aria-haspopup, aria-activedescendant, and keyboard navigation (Arrow keys, Enter, Escape). This is non-trivial to implement correctly — use Headless UI for Vue or Radix Vue instead.
Automated Testing with vue-axe
vue-axe is a wrapper around axe-core that runs accessibility checks in your browser as you develop. It logs violations to the console and optionally renders a panel in the browser — catching issues before they reach your test suite.
Installation (Vue 3)
npm install -D vue-axe
// main.js — dev only
if (process.env.NODE_ENV !== 'production') {
const VueAxe = await import('vue-axe')
app.use(VueAxe.default)
app.component('VueAxePopup', VueAxe.VueAxePopup)
}
// App.vue — add popup in template
<VueAxePopup />For CI testing, use @axe-core/vue with Vitest or Jest + jsdom:
Vitest + axe-core example
import { mount } from '@vue/test-utils'
import { axe } from 'jest-axe'
import MyForm from '@/components/MyForm.vue'
test('MyForm has no accessibility violations', async () => {
const wrapper = mount(MyForm)
const results = await axe(wrapper.element)
expect(results).toHaveNoViolations()
})Automated tools like vue-axe catch roughly 30–40% of WCAG violations — the easy, deterministic ones like missing alt text and invalid ARIA roles. The other 60%+ require manual testing with a real screen reader (NVDA + Firefox on Windows, VoiceOver + Safari on macOS/iOS).
Vue Router: Focus Management on Navigation
Every time Vue Router navigates, screen readers need a signal that a page change happened. There are two approaches:
Option 1: Focus the main heading
// router/index.js
router.afterEach(() => {
nextTick(() => {
const heading = document.querySelector('h1')
if (heading) {
heading.setAttribute('tabindex', '-1')
heading.focus()
}
})
})Option 2: Live region announcement
// App.vue
<div aria-live="polite" aria-atomic="true" class="sr-only">
{{ routeAnnouncement }}
</div>
// router/index.js
router.afterEach((to) => {
app.config.globalProperties.routeAnnouncement =
`Navigated to ${to.meta.title || to.name}`
})Also always update the document title on navigation. In Nuxt 3 use useHead({ title: pageTitle }). In Vue Router, set document.title in the afterEach hook or use vue-meta.
Composition API Accessibility Patterns
useId() for Form Label Associations
When you build reusable form components, each instance needs a unique ID to associate labels with inputs. Vue 3.5+ includes useId() for this:
<script setup>
import { useId } from 'vue'
const id = useId()
</script>
<template>
<label :for="id">Email address</label>
<input :id="id" type="email" v-model="email" />
</template>useFocusTrap() for Modals
Modal dialogs must trap focus — Tab should cycle within the modal, not escape to content behind it. Install focus-trap-vue or use the Headless UI Dialog component:
<script setup>
import { FocusTrap } from 'focus-trap-vue'
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" role="dialog" aria-modal="true"
:aria-labelledby="titleId">
<FocusTrap :active="isOpen">
<div>
<h2 :id="titleId">Dialog Title</h2>
<!-- dialog content -->
<button @click="close">Close</button>
</div>
</FocusTrap>
</div>
</Teleport>
</template>Nuxt 3 Accessibility Checklist
Accessible Component Libraries for Vue
Building accessible dropdowns, comboboxes, date pickers, and tab panels from scratch is weeks of work. These libraries handle the ARIA patterns correctly:
Unstyled, accessible primitives for Vue 3. Ports the battle-tested Radix UI (React) patterns. Covers dialogs, dropdowns, tooltips, tabs, sliders, and more.
Tailwind Labs' headless component library for Vue. Excellent Dialog, Listbox, Combobox, and Menu components with full keyboard support.
Nuxt's official component library built on Headless UI and Tailwind. Accessible by default, pairs with Nuxt 3.
Full-featured Vue UI library with ARIA compliance built in. Strong data grid and form components — useful for enterprise dashboards.
Manual Testing Checklist for Vue Apps
Scan Your Vue App for WCAG Violations
Run an automated accessibility scan on your Vue or Nuxt production URL. Catch issues missed in local dev — especially contrast ratios and missing landmark regions that vary with real content.
Sponsored
Catch accessibility issues as you build in Vue
Deque Axe DevTools integrates with your browser dev tools — spot ARIA errors and contrast failures before code review.