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 router

Combine 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 in nuxt.config.ts.
  • Image component<NuxtImg> requires alt explicitly. 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: serious

For a Vercel / Netlify preview flow, install the axle-vercel-plugin or axle-netlify-plugin directly so scans run inside the build.