Accessibility in Vue Applications

Advanced
12 min

Accessibility in Vue Applications

An accessible application can be used with a keyboard alone, read by a screen reader, and understood by people with low vision or cognitive differences. Single-page applications break several browser defaults that normally provide this for free: route changes do not move focus or announce a new page, custom components replace native controls, and content appears without a page load. After this lesson you will restore those behaviours in Vue with semantic markup, ARIA attributes bound to state, focus management and live announcements.

Semantic HTML First

Most accessibility work is choosing the right element. A <button> is focusable, activates with Enter and Space, and announces itself as a button; a <div @click> does none of that. Use <nav>, <main>, <header>, <button>, <a href>, <label for> and native form controls before reaching for ARIA. Vue does not change any of this, but it makes it easy to forget because everything is a component.

When you do need ARIA, bind it to state like any other attribute:

vue
<script setup> import { ref, useId } from 'vue' const open = ref(false) const panelId = useId() </script> <template> <button :aria-expanded="open" :aria-controls="panelId" @click="open = !open"> Filters </button> <div :id="panelId" v-show="open">...</div> </template>

useId() (Vue 3.5) generates ids that are unique per app instance and stable across server and client rendering, which is exactly what aria-controls, aria-labelledby and <label for> need inside reusable components.

Keyboard Support

Every interactive element must be reachable with Tab and operable with the keyboard. Vue's key modifiers make handlers concise:

vue
<template> <ul role="listbox" tabindex="0" @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter="select()" @keydown.escape="close()"> <li v-for="(opt, i) in options" :key="opt.id" role="option" :aria-selected="i === activeIndex" :class="{ active: i === activeIndex }"> {{ opt.label }} </li> </ul> </template>

Keep a visible focus indicator: never set outline: none without providing a replacement, and use :focus-visible so mouse users do not see rings they do not need. Custom widgets such as tabs, menus and comboboxes have documented keyboard patterns in the WAI-ARIA Authoring Practices; headless libraries implement them so you do not have to.

Focus Management

Focus must move deliberately when the interface changes. Two cases cover most applications.

Route changes. After navigation, move focus to the new page's main heading or container and update the title:

javascript
router.afterEach(async (to) => { document.title = `${to.meta.title ?? 'Page'} - My App` await nextTick() document.querySelector('main h1')?.focus() // give the h1 tabindex="-1" })

Dialogs. When a modal opens, move focus inside it; while it is open, keep Tab cycling within it; when it closes, return focus to the element that opened it. The sample code at the top of this lesson shows the open and return steps with a template ref and a watcher. For trapping Tab inside the dialog, use a focus-trap composable (VueUse's useFocusTrap) or the native <dialog> element with showModal(), which traps focus and handles Escape automatically.

Announcing Dynamic Changes

Screen readers do not notice content that appears silently. A live region announces it:

vue
<template> <div aria-live="polite" class="visually-hidden">{{ statusMessage }}</div> <p v-if="error" role="alert">{{ error }}</p> </template>

Set statusMessage to text such as "3 results found" or "Item added to cart"; polite waits for the user to pause, while role="alert" (assertive) interrupts, so reserve it for errors. Keep one persistent live region in the layout rather than creating and destroying regions, because a region must exist before its content changes to be announced.

Apply the same care to motion: respect prefers-reduced-motion in transitions, and never convey information by color alone.

Tooling and Testing

  • eslint-plugin-vuejs-accessibility flags missing alt text, click handlers without keyboard equivalents and invalid ARIA in templates.
  • axe-core (via vitest-axe or the browser extension) audits rendered output; add it to component tests for shared UI.
  • Test manually: unplug the mouse and walk through a flow with Tab, Enter, Space and the arrow keys, then repeat with a screen reader such as NVDA or VoiceOver.

Common mistakes

  • Building clickable <div> and <span> elements instead of buttons and links.
  • Rendering icon-only buttons without an aria-label.
  • Removing focus outlines globally.
  • Putting aria-hidden="true" on content that is visible and interactive.
Quick Quiz
Question 1 of 2

What does `useId()` provide that a hard-coded id string does not?

Key Takeaways

  • Prefer semantic HTML; bind ARIA attributes to state only where native elements fall short.
  • Use useId() for label and ARIA relationships inside reusable components.
  • Support the keyboard with key modifiers and keep focus indicators visible.
  • Manage focus on route changes and in dialogs; return focus to the trigger on close.
  • Announce dynamic updates with live regions, and verify with linting, axe and manual keyboard testing.

Next lesson: Nuxt and Server-Side Rendering Overview — render Vue on the server for faster first paint and SEO, and see what Nuxt adds on top.