Accessibility in Vue Components: Focus, ARIA and Keyboard Support
AI generated
<v/>
{ }
Vue 3 · Accessibility · ARIA · Keyboard Navigation
Accessibility in Vue Components
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.

18 min read ARIA · Focus Management · Keyboard · Screen Readers · WCAG 2.2 Vue 3.4+ · Composables · Custom Directives

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.

11. FAQ: Accessibility in Vue Components

1What does accessibility in Vue mean concretely?
Semantic HTML, focus management, ARIA for dynamic content, keyboard navigation and color contrast, all for WCAG 2.2 AA conformance.
2Why no <div @click>?
Not focusable, no role, no Enter/Space event. <button> brings all of that natively, with no extra code.
3What is useFocusTrap?
Composable that keeps focus inside a modal. Tabbing cycles within it, Escape closes it, previous focus is restored on close.
4ARIA live regions in Vue?
aria-live="polite" is read aloud by screen readers on content change. A useAnnouncer composable populates it after async operations and form events.
5v-if vs. v-show for screen readers?
v-if removes from the DOM. v-show sets display:none, both remove it from the accessibility tree. Use sr-only for "visible to screen readers, invisible visually."
6Linking error messages accessibly?
aria-describedby on the input points to the ID of the error message, aria-invalid="true" on the error state. Screen readers read the error aloud when the field is focused.
7What is roving tabindex?
Pattern for widgets (tabs, menus): tabindex="-1" on all except the active one. Arrow keys navigate internally, Tab navigates into and out of the widget.
8Accessibility tools for Vue?
@axe-core/vue in dev mode, Lighthouse in CI, NVDA and VoiceOver for manual tests, Colour Contrast Analyser for checking contrast.
9Focus after a Vue Router change?
afterEach hook: wait for nextTick, then focus the h1. Announce the new page title in an aria-live region.
10What does inert do for modals?
inert makes background content invisible to pointer, keyboard and screen reader, preventing navigation through hidden content while a modal is open.