Stack · Vue / Nuxt · accessibility engineering
Vue accessibility — the practical guide
Vue 3 gives you the primitives for accessible components — scoped slots, composables, and the useIdcomposable in 3.5+. Nuxt adds file-based routing, which is where most Vue accessibility bugs come from: focus doesn't move, page titles don't update, and the assistive-tech story breaks at route transitions. This guide covers the Vue-specific patterns that matter for WCAG 2.1 AA — written for teams shipping to markets under EAA 2025, ADA, and Israeli תקנה 35.
Component semantics
The cardinal Vue sin: <div @click="..."> for things that should be <button>. Screen readers don't announce divs as interactive, and they aren't keyboard-focusable by default. The fix is almost always the native element:
<!-- Wrong --> <div class="btn" @click="handle">Submit</div> <!-- Right --> <button type="button" class="btn" @click="handle"> Submit </button>
When you genuinely need a custom interactive pattern (tab list, combobox, disclosure), follow the ARIA Authoring Practices Guide and expose the right roles and state. The headlessui/vue library implements most common patterns correctly; use it rather than hand-rolling.
Forms with useId
Vue 3.5 added useId() — use it. Before 3.5 (and in component libraries that still target 3.4), a ref-counter composable is acceptable but prefer upgrading.
<script setup lang="ts">
import { useId } from 'vue'
const props = defineProps<{
label: string
error?: string
}>()
const id = useId()
const errorId = useId()
</script>
<template>
<div>
<label :for="id">{{ label }}</label>
<input
:id="id"
:aria-invalid="!!error"
:aria-describedby="error ? errorId : undefined"
/>
<p v-if="error" :id="errorId" role="alert">{{ error }}</p>
</div>
</template>Key points: the <label for> wiring is what lets screen readers announce the field name on focus. The aria-describedby wiring is what couples the error message to the input. role="alert" makes the error itself announce the moment it renders.
Focus management across routes
Vue Router (and Nuxt's router) does not move focus on navigation. Keyboard and screen-reader users are left focused on the previous page's activation point — a WCAG 2.4.3 failure. Fix it globally:
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [/* ... */],
})
router.afterEach(() => {
// Defer to next tick so the new page has mounted
requestAnimationFrame(() => {
const target = document.querySelector<HTMLElement>('h1, [role="main"]')
if (target) {
target.setAttribute('tabindex', '-1')
target.focus({ preventScroll: false })
}
})
})
export default routerCombine with updating document.title in the route meta or via useHead (Nuxt) / @vueuse/head. Screen readers read the title on navigation in many combinations.
Dialogs and the <dialog> element
The native <dialog> element handles focus trap, backdrop, and Escape-to-close for you. Prefer it to a custom role="dialog" div:
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{ open: boolean; label: string }>()
const emit = defineEmits<{ close: [] }>()
const dialogRef = ref<HTMLDialogElement | null>(null)
watch(
() => props.open,
(open) => {
if (!dialogRef.value) return
if (open) dialogRef.value.showModal()
else dialogRef.value.close()
},
)
</script>
<template>
<dialog
ref="dialogRef"
:aria-label="label"
@close="emit('close')"
class="rounded-lg p-6"
>
<slot />
</dialog>
</template>If you need headless behaviour (custom animation, portal, nested dialogs), @headlessui/vue's Dialog is the path of least breakage. Do not hand-roll focus trap — the known-bad patterns always bite.
Live regions for async state
Async operations that complete visibly — “Added to cart” toasts, search-result counts, form-submit success — need to announce to screen readers via an ARIA live region.
<template>
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{{ message }}
</div>
</template>
<script setup lang="ts">
defineProps<{ message: string }>()
</script>For urgent updates (errors, time-sensitive warnings) use aria-live="assertive". Use it sparingly — assertive interrupts whatever the user was listening to.
Testing with vitest-axe + Playwright
Component-level with vitest and @vitest-axe/vitest-axe:
// ProductCard.test.ts
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/vue'
import { axe, toHaveNoViolations } from 'vitest-axe'
import ProductCard from './ProductCard.vue'
expect.extend({ toHaveNoViolations })
describe('ProductCard', () => {
it('has no accessibility violations', async () => {
const { container } = render(ProductCard, {
props: { name: 'T-shirt', price: 19.99 },
})
const results = await axe(container)
expect(results).toHaveNoViolations()
})
})Page-level with Playwright and @axe-core/playwright:
// e2e/home.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('homepage is accessible', async ({ page }) => {
await page.goto('/')
const { violations } = await new AxeBuilder({ page }).analyze()
expect(violations).toEqual([])
})Nuxt specifics
- Page title per route — use
useHead({ title: '...' })in every page component. Nuxt's default is the app name only, which fails WCAG 2.4.2. - lang on html — set via
useHead({ htmlAttrs: { lang: 'en' } })or through the app config innuxt.config.ts. - Image component —
<NuxtImg>requiresaltexplicitly. Decorative images:alt="". Never omit. - Nuxt Content — if you render markdown via
<ContentDoc>, audit the generated output; heading hierarchy from author content often skips levels. - Nuxt UI / Nuxt UI Pro — the default components are broadly accessible; verify before shipping overrides.
CI pipeline
Drop this in .github/workflows/accessibility.yml to fail PRs on new WCAG violations:
name: Accessibility
on: [pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: asafamos/axle-action@v1
with:
url: ${{ secrets.PREVIEW_URL }}
fail-on: seriousFor a Vercel / Netlify preview flow, install the axle-vercel-plugin or axle-netlify-plugin directly so scans run inside the build.