Getting focus, ARIA and keyboard support right
Accessibility in Vue applications is not an optional add-on requirement, it is technical quality. Anyone who builds focus management, ARIA attributes and keyboard navigation into Vue components from the very start creates interfaces that work reliably for all users while also meeting legal requirements such as the European Accessibility Act.
Table of Contents
- 1. Why accessibility in Vue components is decisive
- 2. Semantic HTML as the foundation
- 3. Focus management: when and how to set focus programmatically
- 4. ARIA attributes in Vue: aria-label, aria-live and roles
- 5. Keyboard navigation: tabs, arrow keys and escape
- 6. Accessible modals and dialogs in Vue
- 7. Accessible forms and error messages
- 8. Typical accessibility mistakes in Vue projects
- 9. Accessibility patterns in direct comparison
- 10. Summary
- 11. FAQ
1. Why accessibility in Vue components is decisive
Accessibility in Vue components is not a refactoring you bolt on afterwards, it is a design decision that shapes the quality of the application from the first line of code. Starting in June 2025, the European Accessibility Act obliges all B2C providers in the EU to make digital products accessible. But beyond the legal requirements, accessibility also makes technical sense: semantic HTML improves SEO, keyboard navigation helps power users, and focus management makes single-page apps more reliable for all users.
Vue 3's Composition API provides ideal conditions for reusable accessibility solutions. Composables such as useFocusTrap or useAnnouncer encapsulate complex accessibility logic into testable units that can be applied consistently across every component. Custom directives such as v-focus or v-trap-focus bind accessibility behavior declaratively to DOM elements, without boilerplate in every component. Accessibility in Vue components is therefore no longer extra work, just writing code that follows the same standards as the rest of the project.
2. Semantic HTML as the foundation
The strongest tool for accessibility in Vue components is already built into the browser: semantic HTML. A <button> element is keyboard-focusable out of the box, activates on Enter and Space, and is announced correctly by screen readers as a button. A <div> with a click handler achieves the same thing visually, but is invisible to keyboard users and screen readers. The accessibility principle in Vue: never attach a click handler to a non-interactive element without also adding a role, focusability and keyboard events. Doing that far outweighs the effort of simply using the semantically correct element from the start.
In Vue components this means: lists with <ul> and <li>, navigation areas with <nav>, main content with <main>, and landmarks for screen readers. Heading hierarchies (h1 through h6) must reflect the document structure, never an h3 after an h1 without an h2 in between. In single-page apps with Vue Router, correct heading hierarchy matters even more, because page content changes without a reload and screen readers only discover the new structure by actively exploring it. The accessibility pattern for Vue Router: after every route change, set focus to the main heading of the new page and announce the new page title through an ARIA live region.
<!-- BaseButton.vue: semantic, accessible button component -->
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'secondary' | 'danger'
loading?: boolean
disabled?: boolean
ariaLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
loading: false,
disabled: false,
})
// Emits with explicit typing
const emit = defineEmits<{ click: [event: MouseEvent] }>()
// Computed disabled state, also disabled while loading
const isDisabled = computed(() => props.disabled || props.loading)
</script>
<template>
<!-- Native <button>: keyboard-focusable and role="button" for free -->
<button
:disabled="isDisabled"
:aria-disabled="isDisabled"
:aria-busy="loading"
:aria-label="ariaLabel"
:class="['btn', `btn-${variant}`, { 'btn-loading': loading }]"
@click="!isDisabled && emit('click', $event)"
>
<!-- Spinner visually shown, hidden from screenreaders -->
<span v-if="loading" aria-hidden="true" class="spinner" />
<!-- Screen-reader-only loading text -->
<span v-if="loading" class="sr-only">Loading…</span>
<slot v-else />
</button>
</template>
3. Focus management: when and how to set focus programmatically
Focus management is the most complex chapter of accessibility in Vue components. On classic server-rendered pages, the browser automatically sets focus to the top of the document after a page load. In an SPA the content changes without the page reloading, and focus stays wherever it was. That leads to situations where a keyboard user clicks "Place order," the order process runs, and focus afterwards sits on a button that no longer exists or is far away from the new content.
The accessibility pattern in Vue for focus management combines useTemplateRef and nextTick. After an asynchronous operation or a route change, nextTick is awaited before element.focus() is called, making sure the DOM has already updated. A useFocusTrap composable keeps focus inside a region, indispensable for modals, drawers and dropdown menus. The composable remembers the previously focused element and restores it on close, so keyboard users can pick up where they left off. Accessibility in Vue requires this kind of active focus management anywhere visible content changes without the user initiating it through tab navigation.
// composables/useFocusTrap.ts: trap focus within a container
import { ref, onUnmounted, type Ref } from 'vue'
const FOCUSABLE_SELECTORS = [
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
'select:not([disabled])', 'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ')
export function useFocusTrap(containerRef: Ref<HTMLElement | null>) {
const previouslyFocused = ref<HTMLElement | null>(null)
function getFocusable(): HTMLElement[] {
if (!containerRef.value) return []
return Array.from(containerRef.value.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS))
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Tab') return
const focusable = getFocusable()
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
function activate() {
previouslyFocused.value = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
// Focus first focusable element inside container
const focusable = getFocusable()
focusable[0]?.focus()
}
function deactivate() {
document.removeEventListener('keydown', handleKeydown)
previouslyFocused.value?.focus()
previouslyFocused.value = null
}
onUnmounted(deactivate)
return { activate, deactivate }
}
4. ARIA attributes in Vue: aria-label, aria-live and roles
ARIA (Accessible Rich Internet Applications) supplements semantic HTML wherever HTML elements alone are not enough to convey the meaning of an interaction. In Vue, ARIA attributes are bound like any other attribute, reactively with :aria-expanded="isOpen" or statically with aria-label="Search field". The most important principle for accessibility in Vue components: never use ARIA attributes without a semantic HTML foundation underneath. A role="button" on a <div> declares the role, but not focusability or keyboard behavior, all of which then has to be added manually, which makes using <button> the easier option in every case.
ARIA live regions are the crucial tool for dynamic content changes in Vue SPAs. An element with aria-live="polite" is read aloud by screen readers as soon as its content changes, without the user having to focus it actively. The pattern for Vue: an invisible <div aria-live="polite" aria-atomic="true"> region in the app root, populated with status messages through a useAnnouncer composable. When search results have loaded, when a form has been submitted successfully, when an error message appears, all of that belongs in the announcer, so screen reader users have the same information as sighted users.
5. Keyboard navigation: tabs, arrow keys and escape
Full keyboard navigation is at the heart of accessibility in Vue components. Tab order follows DOM order, which means that CSS reordering with order or flex-direction: column-reverse can pull the visual order and the keyboard order apart, which is disorienting for keyboard users. In Vue, DOM order should always match the visual reading and interaction order, even when that occasionally requires different CSS approaches.
For composite widgets such as menu bars, tabs, listboxes and trees, the ARIA Authoring Practices Guide defines a specific keyboard pattern: Tab moves focus into and out of the widget, arrow keys navigate within the widget. This prevents keyboard users from having to tab through long lists of menu items. In Vue, this pattern is implemented with a useRovingFocus composable that sets tabindex="-1" on every element except the active one and manages arrow-key navigation. Escape always closes the nearest overlaying parent element, modal, dropdown, tooltip, and returns focus to the triggering element.
// composables/useRovingFocus.ts: arrow-key navigation for composite widgets
import { ref, type Ref } from 'vue'
export function useRovingFocus(items: Ref<HTMLElement[]>) {
const activeIndex = ref(0)
function setActive(index: number) {
const clamped = Math.max(0, Math.min(index, items.value.length - 1))
// Remove tabindex from all, set on active only
items.value.forEach((el, i) => {
el.setAttribute('tabindex', i === clamped ? '0' : '-1')
})
items.value[clamped]?.focus()
activeIndex.value = clamped
}
function handleKeydown(event: KeyboardEvent) {
const count = items.value.length
if (!count) return
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault()
setActive((activeIndex.value + 1) % count)
break
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault()
setActive((activeIndex.value - 1 + count) % count)
break
case 'Home':
event.preventDefault()
setActive(0)
break
case 'End':
event.preventDefault()
setActive(count - 1)
break
}
}
// Initialize: first item focusable, rest not
function init() {
items.value.forEach((el, i) => {
el.setAttribute('tabindex', i === 0 ? '0' : '-1')
})
activeIndex.value = 0
}
return { activeIndex, handleKeydown, setActive, init }
}
6. Accessible modals and dialogs in Vue
Modals are the most common source of accessibility mistakes in Vue components. An inaccessible modal lets keyboard users tab through the background content even though the modal is open. Screen readers read out the background content, because it is not marked as "hidden" in the DOM. Closing with Escape does not work. The correct accessibility pattern for Vue modals rests on three pillars: a focus trap, aria-modal="true" plus role="dialog", and the inert attribute on the background content. The HTML inert attribute makes all content of an element invisible to pointer, keyboard and screen reader, the most elegant tool for neutralizing the background while a modal is open.
Vue's Teleport component is the ideal partner for accessible modals. With <Teleport to="body">, the modal HTML is rendered directly as a child of <body>, regardless of where it is declared in the component hierarchy. This prevents z-index problems and ensures that aria-modal works correctly, because the modal is not sitting inside a container with overflow: hidden. On open, focus must be set to the close button or to the first focusable action inside the dialog, not to the surrounding container element, which is not itself interactive.
7. Accessible forms and error messages
Forms are the area where accessibility in Vue components most directly affects usability. Every form field needs an explicitly linked label, either through <label for="id"> with a matching id on the input, or through aria-labelledby or aria-label. Placeholder text is not a substitute for labels: it disappears while typing, has too little contrast, and is not read consistently by older screen readers. In Vue form components, IDs are generated dynamically with a composable, useId(), natively available since Vue 3.5, which guarantees unique IDs even with server-side rendering.
Error messages must be linked to the field programmatically, not just placed visually underneath. The accessibility pattern: aria-describedby on the input points to the ID of the error message, aria-invalid="true" signals the error state. Screen readers automatically read the linked error message when the field is focused. Error states that appear after asynchronous validation should additionally go into an ARIA live region, so screen reader users are informed immediately without having to leave and re-enter the field. Forms that follow these accessibility standards are not only more accessible, they also convert better, because users can fill them in faster and with more confidence.
8. Typical accessibility mistakes in Vue projects
The most common accessibility mistake in Vue projects is the div-soup antipattern: interactive elements built as <div> with a @click handler, without focusability, without a role and without keyboard events. An icon button without visible text needs an aria-label, without it the screen reader only says "button" without explaining what it does. v-if and v-show behave differently for screen readers: v-if removes the element from the DOM entirely, v-show only sets display: none. Elements hidden with v-show are no longer accessible to screen readers, because display: none removes the element from the accessibility tree. Anyone who wants to hide content from sighted users while keeping it visible to screen readers uses the sr-only CSS class.
A second common mistake: color contrast gets discovered as a problem after development, not during it. The WCAG 2.2 AA criterion requires at least a 4.5:1 contrast ratio for normal text and 3:1 for large text (from 18pt or 14pt bold). Tailwind CSS classes like text-gray-400 on a white background regularly fail this criterion. In Vue projects it is worth integrating axe-core or @axe-core/vue as a dev dependency, which reports accessibility violations directly in the browser console during development, so problems get found before they reach production.
9. Accessibility patterns in direct comparison
In Vue projects, many interaction patterns have both an accessible and an inaccessible variant. The choice has direct consequences for WCAG conformance and for usability by people with disabilities.
| Scenario | Inaccessible | Accessible pattern | WCAG criterion |
|---|---|---|---|
| Button | <div @click> |
<button> |
4.1.2 Name, Role, Value |
| Icon button | No label | aria-label="Close" |
1.1.1 Non-text Content |
| Error message | Only visually below | aria-describedby + aria-invalid |
3.3.1 Error Identification |
| Modal | No focus trap | useFocusTrap + inert |
2.1.2 No Keyboard Trap |
| Route change | Focus stays where it was | Focus on h1, announce page | 2.4.3 Focus Order |
The table shows that every accessibility problem maps to a concrete WCAG criterion, and that the accessible variant in Vue projects is almost always based on native HTML elements and ARIA attributes, not on elaborate custom implementations. The accessibility pattern is often the simpler and more robust solution, not the more complicated one.
Mironsoft
Vue 3 accessibility audits, WCAG implementation and accessible component libraries
Need your Vue app to meet WCAG 2.2 AA?
We audit existing Vue applications for accessibility violations and implement accessible patterns, focus management, ARIA, keyboard support and color contrast for the European Accessibility Act.
A11y audit
Automated testing with axe-core and manual screen reader tests with NVDA and VoiceOver
Composables
useFocusTrap, useAnnouncer, useRovingFocus, reusable accessibility building blocks
WCAG implementation
AA conformance for all critical user flows, forms, modals, navigation and notifications
10. Summary
Implementing accessibility in Vue components is not a one-time task, it is an ongoing practice. Semantic HTML delivers the strongest foundation, native elements bring focusability, roles and keyboard behavior for free. Focus management with useFocusTrap and targeted focus() calls after nextTick ensures keyboard users in SPAs always know where they are. ARIA attributes such as aria-live, aria-expanded and aria-describedby convey dynamic state changes to screen readers. Keyboard navigation with roving tabindex in composite widgets follows the ARIA Authoring Practices.
The biggest lever in Vue projects lies in standardization through composables and base components. Anyone who builds BaseButton, BaseModal and BaseFormField once with a complete accessibility implementation gets that quality in every component that uses these building blocks, without every team member having to keep all ARIA patterns in their head. Accessibility in Vue components scales through good architecture, not through individual expertise on every commit.
Accessibility in Vue components: the essentials at a glance
Semantic HTML
Always use native elements: <button>, <nav>, <main>. No <div @click> without full ARIA support.
Focus management
useFocusTrap for modals, nextTick + focus() after route changes, restore the previous element on close.
ARIA live
useAnnouncer for dynamic status messages. aria-describedby for error messages. aria-expanded for toggleable regions.
Tools
@axe-core/vue in dev mode, test NVDA/VoiceOver manually, integrate the Lighthouse accessibility score into CI.