RatedWithAI

RatedWithAI

Accessibility scanner

Vue.js Accessibility Guide 2026: WCAG Compliance for Vue & Nuxt Apps

Updated June 2026·12 min read·Developer Guide

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 useHead for 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.

CriticalClick handlers on non-interactive elements

@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.

CriticalVue Router page changes without focus management

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.

Highv-show vs v-if and ARIA visibility

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.

HighReactive content not announced to screen readers

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".

HighMissing labels on form inputs

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.

HighCustom dropdown/select components without ARIA

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

Set page title on every route with useHead({ title })
Add nuxt-a11y module for automated route announcement
Use NuxtLink instead of <a> for internal navigation (handles focus correctly)
Wrap dynamic content regions in <LazyHydrate> with aria-live
Test with SSR disabled (hydration timing creates ARIA state mismatches)
Add skip navigation link as first focusable element in default.vue layout
Validate color contrast in Tailwind utility classes (bg-* text-* pairs)

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:

Radix VueHeadless

Unstyled, accessible primitives for Vue 3. Ports the battle-tested Radix UI (React) patterns. Covers dialogs, dropdowns, tooltips, tabs, sliders, and more.

Headless UI for VueHeadless

Tailwind Labs' headless component library for Vue. Excellent Dialog, Listbox, Combobox, and Menu components with full keyboard support.

Nuxt UIStyled

Nuxt's official component library built on Headless UI and Tailwind. Accessible by default, pairs with Nuxt 3.

PrimeVueStyled

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

Navigate all routes with keyboard only (Tab, Shift+Tab, Enter, Space, arrow keys)
Verify focus is visible on all interactive elements (don't suppress outline without a replacement)
Test Vue Router navigation: does focus move to content? Does the page title update?
Open and close all modals/drawers: does focus trap work? Does it return to the trigger?
Check all forms: do labels announce with inputs? Do errors announce on submission?
Test with NVDA + Firefox (Windows) or VoiceOver + Safari (macOS/iOS)
Run vue-axe in dev mode and clear all violations before shipping
Check color contrast ratios for all text (minimum 4.5:1 for normal text, 3:1 for large)
Verify all images have descriptive alt text (decorative images: alt="")
Test reactive content: do live region updates announce to screen readers?

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.

Try Free →

Related Guides