Skip to main content

Vue.js Accessibility: Building Inclusive Applications with Vue 3

Vue.js provides a reactive, component-based architecture that can produce highly accessible applications when used correctly. However, the framework's dynamic rendering, client-side routing, and component abstractions introduce accessibility challenges that require deliberate solutions. This guide covers Vue 3 accessibility patterns using the Composition API, from foundational techniques to advanced composables and testing strategies.

Template Refs for Focus Management

Vue's template refs provide direct DOM access for programmatic focus management, which is essential for interactive components like modals, dropdowns, and form validation flows. Unlike vanilla JavaScript's document.querySelector, template refs are scoped to the component and reactive.

<template>
  <div>
    <button @click="openSearch">Search</button>
    <div v-if="isSearchOpen" role="search">
      <label for="search-input">Search articles</label>
      <input
        ref="searchInput"
        id="search-input"
        type="search"
        v-model="query"
        @keydown.escape="closeSearch"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, nextTick } from 'vue'

const searchInput = ref(null)
const isSearchOpen = ref(false)
const query = ref('')

async function openSearch() {
  isSearchOpen.value = true
  await nextTick()
  searchInput.value?.focus()
}

function closeSearch() {
  isSearchOpen.value = false
  query.value = ''
  // Return focus to the trigger button
}
</script>

The nextTick call is critical: Vue batches DOM updates, so the input element does not exist in the DOM immediately after setting isSearchOpen to true. Without nextTick, searchInput.value would be null.

Binding ARIA Attributes with v-bind

Vue's v-bind directive (or its : shorthand) makes it straightforward to set ARIA attributes dynamically based on component state. This ensures that the accessibility tree always reflects the current UI state.

<template>
  <div>
    <button
      :aria-expanded="isOpen"
      :aria-controls="panelId"
      @click="toggle"
    >
      {{ title }}
    </button>
    <div
      v-show="isOpen"
      :id="panelId"
      role="region"
      :aria-labelledby="buttonId"
    >
      <slot />
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  title: String,
  id: String,
})

const isOpen = ref(false)
const panelId = computed(() => `panel-${props.id}`)
const buttonId = computed(() => `button-${props.id}`)

function toggle() {
  isOpen.value = !isOpen.value
}
</script>

Always use computed properties for generated IDs to ensure consistency between the controlling element and the controlled region. Hardcoded IDs lead to collisions when components are reused.

Single File Components and Semantic HTML

Vue Single File Components (SFCs) encapsulate template, logic, and styles in a single .vue file. This structure makes it easy to enforce semantic HTML at the component level, but it also creates a risk: developers may over-rely on <div> elements and CSS for visual structure rather than using meaningful HTML elements.

  • Use <nav>, <main>, <aside>, <header>, <footer>, and <section> in your layout components
  • Prefer <button> over <div @click> for interactive elements — buttons provide keyboard handling and screen reader semantics for free
  • Use <ul> and <li> for lists of items, even when styled as cards or chips
  • Heading levels (<h1> through <h6>) should follow document outline, not visual size
  • Use the <template> root to avoid unnecessary wrapper <div> elements — Vue 3 supports multiple root nodes (fragments)
<!-- Bad: div soup -->
<template>
  <div class="card">
    <div class="card-header" @click="navigate">
      <div class="title">{{ article.title }}</div>
    </div>
    <div class="card-body">{{ article.excerpt }}</div>
  </div>
</template>

<!-- Good: semantic HTML -->
<template>
  <article>
    <header>
      <h3>
        <router-link :to="article.url">{{ article.title }}</router-link>
      </h3>
    </header>
    <p>{{ article.excerpt }}</p>
  </article>
</template>

Vue Router: Route Announcements and Focus Management

Single-page applications built with Vue Router do not trigger full page loads, which means screen readers receive no automatic notification when the user navigates to a new view. This is one of the most significant accessibility barriers in SPAs. You need to handle three things: announce the new page, move focus appropriately, and update the document title.

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: () => import('../views/Home.vue'),
      meta: { title: 'Home' },
    },
    {
      path: '/products',
      component: () => import('../views/Products.vue'),
      meta: { title: 'Products' },
    },
  ],
})

router.afterEach((to) => {
  // Update document title
  const title = to.meta.title
    ? `${to.meta.title} — My App`
    : 'My App'
  document.title = title

  // Announce the route change to screen readers
  const announcer = document.getElementById('route-announcer')
  if (announcer) {
    announcer.textContent = ''
    setTimeout(() => {
      announcer.textContent = `Navigated to ${to.meta.title || 'new page'}`
    }, 100)
  }

  // Move focus to the main content area
  setTimeout(() => {
    const main = document.getElementById('main-content')
    if (main) {
      main.setAttribute('tabindex', '-1')
      main.focus()
      main.removeAttribute('tabindex')
    }
  }, 150)
})

export default router

The route announcer element should be placed in your root App.vue component:

<template>
  <div id="app">
    <div
      id="route-announcer"
      role="status"
      aria-live="assertive"
      aria-atomic="true"
      class="sr-only"
    ></div>
    <a href="#main-content" class="sr-only focus:not-sr-only">
      Skip to main content
    </a>
    <AppHeader />
    <main id="main-content">
      <router-view />
    </main>
    <AppFooter />
  </div>
</template>

The short setTimeout delays are necessary because screen readers need a brief pause to detect content changes in live regions, and the DOM must be updated before focus can move to the new content.

Accessible Forms: v-model with Labels and Validation

Vue's v-model directive provides two-way binding for form inputs, but it does nothing for accessibility by itself. Every input must have an associated label, error messages must be programmatically linked, and validation feedback must be announced to screen readers.

<template>
  <form @submit.prevent="handleSubmit" novalidate>
    <div>
      <label for="email">Email address</label>
      <input
        id="email"
        type="email"
        v-model="email"
        :aria-invalid="!!errors.email"
        :aria-describedby="errors.email ? 'email-error' : undefined"
        @blur="validateEmail"
      />
      <p v-if="errors.email" id="email-error" role="alert">
        {{ errors.email }}
      </p>
    </div>

    <div>
      <label for="password">Password</label>
      <input
        id="password"
        type="password"
        v-model="password"
        :aria-invalid="!!errors.password"
        :aria-describedby="passwordDescriptions"
      />
      <p id="password-hint">Must be at least 8 characters</p>
      <p v-if="errors.password" id="password-error" role="alert">
        {{ errors.password }}
      </p>
    </div>

    <button type="submit">Create account</button>
  </form>
</template>

<script setup>
import { ref, computed } from 'vue'

const email = ref('')
const password = ref('')
const errors = ref({})

const passwordDescriptions = computed(() => {
  const ids = ['password-hint']
  if (errors.value.password) ids.push('password-error')
  return ids.join(' ')
})

function validateEmail() {
  if (!email.value) {
    errors.value.email = 'Email is required'
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
    errors.value.email = 'Please enter a valid email address'
  } else {
    delete errors.value.email
  }
}

function handleSubmit() {
  validateEmail()
  // additional validation...
}
</script>

Form Validation with vee-validate

For complex forms, vee-validate integrates cleanly with Vue 3's Composition API and provides built-in support for accessible error handling.

<template>
  <form @submit="onSubmit">
    <div>
      <label for="name">Full name</label>
      <input
        id="name"
        v-bind="nameAttrs"
        v-model="name"
        :aria-invalid="!!nameError"
        :aria-describedby="nameError ? 'name-error' : undefined"
      />
      <p v-if="nameError" id="name-error" role="alert">
        {{ nameError }}
      </p>
    </div>
    <button type="submit">Submit</button>
  </form>
</template>

<script setup>
import { useForm, useField } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

const schema = toTypedSchema(
  z.object({
    name: z.string().min(1, 'Name is required').max(100),
  })
)

const { handleSubmit } = useForm({ validationSchema: schema })

const { value: name, errorMessage: nameError, attrs: nameAttrs } = useField('name')

const onSubmit = handleSubmit((values) => {
  // Process form data
})
</script>

Dynamic Content: v-if, v-show, and Screen Readers

The choice between v-if and v-show has direct implications for assistive technology. Understanding the difference is essential for building accessible dynamic interfaces.

  • v-if adds and removes elements from the DOM entirely. When an element is removed, it disappears from the accessibility tree immediately. When it is added, screen readers will not announce it unless a live region is involved.
  • v-show toggles display: none on the element. The element remains in the DOM but is hidden from both visual display and the accessibility tree. Toggling it back makes it visible and accessible again.

For content that should be announced when it appears, use v-if inside a container with aria-live:

<template>
  <div>
    <button @click="loadResults">Search</button>

    <!-- The aria-live container must always be in the DOM -->
    <div aria-live="polite" aria-atomic="false">
      <p v-if="isLoading">Loading results...</p>
      <div v-else-if="results.length">
        <p>{{ results.length }} results found</p>
        <ul>
          <li v-for="result in results" :key="result.id">
            {{ result.title }}
          </li>
        </ul>
      </div>
      <p v-else-if="hasSearched">No results found</p>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const isLoading = ref(false)
const hasSearched = ref(false)
const results = ref([])

async function loadResults() {
  isLoading.value = true
  hasSearched.value = true
  try {
    results.value = await fetchResults()
  } finally {
    isLoading.value = false
  }
}
</script>

The key rule: the aria-live container must be present in the DOM before the dynamic content changes. If you put aria-live on an element that is itself conditionally rendered with v-if, screen readers will not register it as a live region when it first appears.

Teleport for Modals and Dialogs

Vue's <Teleport> component renders content at a different location in the DOM tree, which is essential for modals and dialogs. Without teleporting, a modal rendered inside a deeply nested component might be clipped by overflow: hidden ancestors, or its z-index stacking context could cause visual problems. Teleporting to document.body solves these issues and also simplifies the accessibility implementation.

<template>
  <button @click="openModal" ref="triggerButton">
    Delete item
  </button>

  <Teleport to="body">
    <div
      v-if="isOpen"
      class="modal-overlay"
      @click.self="closeModal"
      @keydown.escape="closeModal"
    >
      <div
        ref="modalElement"
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        aria-describedby="modal-desc"
        tabindex="-1"
      >
        <h2 id="modal-title">Confirm deletion</h2>
        <p id="modal-desc">
          This action cannot be undone. Are you sure you want to delete this item?
        </p>
        <div>
          <button @click="closeModal">Cancel</button>
          <button @click="confirmDelete">Delete</button>
        </div>
      </div>
    </div>
  </Teleport>
</template>

<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'

const isOpen = ref(false)
const modalElement = ref(null)
const triggerButton = ref(null)

async function openModal() {
  isOpen.value = true
  await nextTick()
  modalElement.value?.focus()
  trapFocus()
}

function closeModal() {
  isOpen.value = false
  triggerButton.value?.focus()
}

function confirmDelete() {
  // perform deletion
  closeModal()
}

function trapFocus() {
  const modal = modalElement.value
  if (!modal) return

  const focusable = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  )
  const first = focusable[0]
  const last = focusable[focusable.length - 1]

  function handleTab(e) {
    if (e.key !== 'Tab') return
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault()
      last.focus()
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault()
      first.focus()
    }
  }

  modal.addEventListener('keydown', handleTab)
}
</script>

The critical accessibility requirements for modals are: set role="dialog" and aria-modal="true", move focus into the dialog when it opens, trap focus within the dialog while it is open, return focus to the trigger element when it closes, and close on Escape key.

Composables for Accessibility

Vue 3's Composition API enables reusable accessibility logic through composables. These are functions that encapsulate reactive state and side effects, making it easy to share accessibility patterns across components.

useFocusTrap

A focus trap composable keeps keyboard focus confined within a container element, which is required for modals, drawers, and other overlay content.

// composables/useFocusTrap.js
import { watch, onUnmounted } from 'vue'

export function useFocusTrap(containerRef, isActive) {
  let previouslyFocused = null

  function getFocusableElements() {
    if (!containerRef.value) return []
    return Array.from(
      containerRef.value.querySelectorAll(
        'a[href], button:not([disabled]), input:not([disabled]), ' +
        'select:not([disabled]), textarea:not([disabled]), ' +
        '[tabindex]:not([tabindex="-1"])'
      )
    )
  }

  function handleKeyDown(event) {
    if (event.key !== 'Tab') return

    const focusable = getFocusableElements()
    if (focusable.length === 0) 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()
    }
  }

  watch(isActive, (active) => {
    if (active) {
      previouslyFocused = document.activeElement
      document.addEventListener('keydown', handleKeyDown)
      const focusable = getFocusableElements()
      if (focusable.length) focusable[0].focus()
    } else {
      document.removeEventListener('keydown', handleKeyDown)
      previouslyFocused?.focus()
      previouslyFocused = null
    }
  })

  onUnmounted(() => {
    document.removeEventListener('keydown', handleKeyDown)
  })
}

useAnnounce

A composable for announcing dynamic content changes to screen readers via a live region.

// composables/useAnnounce.js
import { ref } from 'vue'

const message = ref('')
const politeness = ref('polite')

export function useAnnounce() {
  function announce(text, priority = 'polite') {
    politeness.value = priority
    message.value = ''
    // Clear then set to force re-announcement of identical messages
    setTimeout(() => {
      message.value = text
    }, 50)
  }

  return {
    message,
    politeness,
    announce,
  }
}

// AnnounceProvider.vue — mount once at root level
// <template>
//   <div
//     :aria-live="politeness"
//     aria-atomic="true"
//     class="sr-only"
//   >
//     {{ message }}
//   </div>
//   <slot />
// </template>

useReducedMotion

This composable detects the user's motion preference and provides a reactive boolean that components can use to disable or simplify animations.

// composables/useReducedMotion.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useReducedMotion() {
  const prefersReducedMotion = ref(false)
  let mediaQuery = null

  function update(event) {
    prefersReducedMotion.value = event.matches
  }

  onMounted(() => {
    mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
    prefersReducedMotion.value = mediaQuery.matches
    mediaQuery.addEventListener('change', update)
  })

  onUnmounted(() => {
    mediaQuery?.removeEventListener('change', update)
  })

  return { prefersReducedMotion }
}

Usage in a component:

<template>
  <Transition :name="prefersReducedMotion ? '' : 'slide-fade'">
    <div v-if="isVisible">Animated content</div>
  </Transition>
</template>

<script setup>
import { ref } from 'vue'
import { useReducedMotion } from '@/composables/useReducedMotion'

const isVisible = ref(true)
const { prefersReducedMotion } = useReducedMotion()
</script>

Transition and TransitionGroup: Respecting prefers-reduced-motion

Vue's built-in <Transition> and <TransitionGroup> components animate elements entering and leaving the DOM. While animations can enhance the user experience, they can cause discomfort or disorientation for users with vestibular disorders. You should always respect the prefers-reduced-motion media query.

The simplest approach is to use CSS to disable transitions when reduced motion is preferred:

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .fade-enter-active,
  .fade-leave-active {
    transition: none;
  }
}
</style>

For JavaScript-driven animations, use the useReducedMotion composable to conditionally set animation duration to zero or skip animations entirely:

<template>
  <TransitionGroup
    tag="ul"
    :name="prefersReducedMotion ? '' : 'list'"
    @before-enter="onBeforeEnter"
    @enter="onEnter"
  >
    <li v-for="item in items" :key="item.id">
      {{ item.text }}
    </li>
  </TransitionGroup>
</template>

<script setup>
import { useReducedMotion } from '@/composables/useReducedMotion'

const { prefersReducedMotion } = useReducedMotion()

function onBeforeEnter(el) {
  if (prefersReducedMotion.value) return
  el.style.opacity = 0
  el.style.transform = 'translateY(20px)'
}

function onEnter(el, done) {
  if (prefersReducedMotion.value) {
    done()
    return
  }
  el.animate(
    [
      { opacity: 0, transform: 'translateY(20px)' },
      { opacity: 1, transform: 'translateY(0)' },
    ],
    { duration: 300, easing: 'ease-out' }
  ).onfinish = done
}
</script>

Component Libraries with Accessibility Built In

Several Vue component libraries ship with robust accessibility support, saving you from implementing complex ARIA patterns from scratch.

Headless UI Vue

Headless UI provides completely unstyled, fully accessible UI components for Vue 3. It handles all ARIA attributes, keyboard navigation, and focus management internally, letting you apply your own styles.

<template>
  <Listbox v-model="selected">
    <ListboxLabel>Assign to</ListboxLabel>
    <ListboxButton>{{ selected.name }}</ListboxButton>
    <ListboxOptions>
      <ListboxOption
        v-for="person in people"
        :key="person.id"
        :value="person"
        v-slot="{ active, selected: isSelected }"
      >
        <span :class="{ 'font-bold': isSelected }">
          {{ person.name }}
        </span>
      </ListboxOption>
    </ListboxOptions>
  </Listbox>
</template>

<script setup>
import { ref } from 'vue'
import {
  Listbox,
  ListboxLabel,
  ListboxButton,
  ListboxOptions,
  ListboxOption,
} from '@headlessui/vue'

const people = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
  { id: 3, name: 'Charlie' },
]
const selected = ref(people[0])
</script>

Headless UI handles arrow key navigation, type-ahead search, proper role and aria-* attributes, and focus management automatically.

Radix Vue

Radix Vue is a port of Radix UI primitives to Vue. It provides low-level accessible components like Dialog, Popover, Tooltip, Tabs, and Accordion with full keyboard support and screen reader compatibility.

<template>
  <TabsRoot v-model="activeTab">
    <TabsList aria-label="Account settings">
      <TabsTrigger value="profile">Profile</TabsTrigger>
      <TabsTrigger value="security">Security</TabsTrigger>
      <TabsTrigger value="notifications">Notifications</TabsTrigger>
    </TabsList>
    <TabsContent value="profile">
      <!-- Profile settings form -->
    </TabsContent>
    <TabsContent value="security">
      <!-- Security settings form -->
    </TabsContent>
    <TabsContent value="notifications">
      <!-- Notification preferences -->
    </TabsContent>
  </TabsRoot>
</template>

<script setup>
import { ref } from 'vue'
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from 'radix-vue'

const activeTab = ref('profile')
</script>

PrimeVue Accessibility

PrimeVue is a full-featured component library that includes accessibility features such as keyboard navigation, ARIA attributes, and screen reader support. Its components follow WAI-ARIA design patterns and provide configurable aria-label and aria-labelledby props.

<template>
  <DataTable
    :value="products"
    :paginator="true"
    :rows="10"
    aria-label="Products table"
    :sortField="sortField"
    :sortOrder="sortOrder"
    @sort="onSort"
  >
    <Column field="name" header="Name" sortable />
    <Column field="category" header="Category" sortable />
    <Column field="price" header="Price" sortable />
  </DataTable>
</template>

Nuxt.js Accessibility Specifics

Nuxt.js builds on Vue and adds server-side rendering, automatic routing, and metadata management. These features have direct implications for accessibility.

useHead for Metadata

Nuxt's useHead composable sets the document title and meta tags, which is critical for screen reader users who rely on the page title to understand where they are.

<script setup>
useHead({
  title: 'Product Details — My Store',
  meta: [
    {
      name: 'description',
      content: 'View detailed information about this product',
    },
  ],
  htmlAttrs: {
    lang: 'en',
  },
})
</script>

Setting the lang attribute via htmlAttrs ensures screen readers use the correct pronunciation rules. For multilingual sites, update this dynamically based on the current locale.

NuxtLink

Nuxt's <NuxtLink> component handles client-side navigation and prefetching. It renders as a standard <a> element, which is inherently accessible. However, you should still provide descriptive link text:

<!-- Bad: vague link text -->
<NuxtLink to="/pricing">Click here</NuxtLink>

<!-- Good: descriptive link text -->
<NuxtLink to="/pricing">View pricing plans</NuxtLink>

<!-- Good: icon link with accessible label -->
<NuxtLink to="/settings" aria-label="Account settings">
  <IconGear />
</NuxtLink>

Nuxt Image

The <NuxtImg> component optimizes images with responsive sizing and lazy loading. Always include meaningful alt text, or an empty alt="" for decorative images:

<!-- Informative image -->
<NuxtImg
  src="/photos/team.jpg"
  alt="Our team of 12 engineers gathered around a whiteboard during a planning session"
  width="800"
  height="600"
  loading="lazy"
/>

<!-- Decorative image -->
<NuxtImg
  src="/patterns/dots.svg"
  alt=""
  role="presentation"
  width="200"
  height="200"
/>

Provide/Inject for Accessibility Context

Vue's provide and inject mechanism lets you share accessibility-related state across a component tree without prop drilling. This is useful for themes, announcer services, and shared accessibility configuration.

// Providing an announcer service at the app level
// App.vue
<template>
  <div>
    <div
      ref="liveRegion"
      aria-live="polite"
      aria-atomic="true"
      class="sr-only"
    >
      {{ announcement }}
    </div>
    <slot />
  </div>
</template>

<script setup>
import { ref, provide } from 'vue'

const announcement = ref('')

function announce(message, priority = 'polite') {
  announcement.value = ''
  setTimeout(() => {
    announcement.value = message
  }, 50)
}

provide('announce', announce)
provide('theme', {
  highContrast: ref(false),
  fontSize: ref('normal'),
})
</script>
// Consuming the announcer in any descendant component
<script setup>
import { inject } from 'vue'

const announce = inject('announce')
const theme = inject('theme')

function addToCart(product) {
  // ... add product logic
  announce(`${product.name} added to cart`)
}
</script>

Using symbols as injection keys prevents naming collisions and provides better TypeScript support:

// keys.js
export const AnnounceKey = Symbol('announce')
export const ThemeKey = Symbol('theme')

// Provider
import { AnnounceKey } from './keys'
provide(AnnounceKey, announce)

// Consumer
import { AnnounceKey } from './keys'
const announce = inject(AnnounceKey)

Testing Vue Accessibility

Automated testing catches a significant portion of accessibility issues before they reach production. Vue's testing ecosystem integrates well with accessibility testing tools.

@vue/test-utils with axe-core

Combine Vue Test Utils for component rendering with axe-core for automated accessibility auditing:

// tests/components/LoginForm.test.js
import { mount } from '@vue/test-utils'
import { axe, toHaveNoViolations } from 'jest-axe'
import LoginForm from '@/components/LoginForm.vue'

expect.extend(toHaveNoViolations)

describe('LoginForm', () => {
  it('has no accessibility violations', async () => {
    const wrapper = mount(LoginForm)
    const results = await axe(wrapper.element)
    expect(results).toHaveNoViolations()
  })

  it('shows error with proper ARIA attributes', async () => {
    const wrapper = mount(LoginForm)
    const emailInput = wrapper.find('#email')

    await emailInput.setValue('')
    await wrapper.find('form').trigger('submit')

    const error = wrapper.find('[role="alert"]')
    expect(error.exists()).toBe(true)

    expect(emailInput.attributes('aria-invalid')).toBe('true')
    expect(emailInput.attributes('aria-describedby')).toContain('email-error')
  })

  it('moves focus to first error on submit', async () => {
    const wrapper = mount(LoginForm, {
      attachTo: document.body,
    })

    await wrapper.find('form').trigger('submit')

    expect(document.activeElement).toBe(
      wrapper.find('#email').element
    )

    wrapper.unmount()
  })
})

eslint-plugin-vuejs-accessibility

This ESLint plugin catches common accessibility issues at the template level during development, before code even runs:

// .eslintrc.js
module.exports = {
  extends: [
    'plugin:vue/vue3-recommended',
    'plugin:vuejs-accessibility/recommended',
  ],
  rules: {
    // Require alt text on images
    'vuejs-accessibility/alt-text': 'error',

    // Require labels on form elements
    'vuejs-accessibility/form-control-has-label': 'error',

    // Prevent click events without keyboard events
    'vuejs-accessibility/click-events-have-key-events': 'error',

    // Require interactive role on non-interactive elements with handlers
    'vuejs-accessibility/interactive-supports-focus': 'error',

    // Require valid anchor href values
    'vuejs-accessibility/anchor-has-content': 'error',

    // Prevent autofocus attribute
    'vuejs-accessibility/no-autofocus': 'warn',

    // Require onBlur with onChange
    'vuejs-accessibility/no-onchange': 'error',
  },
}

The plugin flags issues directly in your editor and CI pipeline, catching problems like missing alt attributes, click handlers without keyboard equivalents, and form controls without labels.

Common Vue Accessibility Mistakes

The following examples illustrate frequent accessibility mistakes in Vue applications and how to fix them.

Mistake 1: Using div as a button

<!-- Before: inaccessible -->
<template>
  <div class="btn" @click="save">
    Save changes
  </div>
</template>

<!-- After: accessible -->
<template>
  <button type="button" @click="save">
    Save changes
  </button>
</template>

A <div> has no implicit role, is not focusable by keyboard, and does not respond to Enter or Space key presses. A <button> provides all of these behaviors natively.

Mistake 2: Missing live region for async feedback

<!-- Before: screen readers do not announce the status -->
<template>
  <div>
    <button @click="save">Save</button>
    <span v-if="saved">Changes saved!</span>
  </div>
</template>

<!-- After: screen readers announce the status message -->
<template>
  <div>
    <button @click="save">Save</button>
    <div aria-live="polite">
      <span v-if="saved">Changes saved!</span>
    </div>
  </div>
</template>

Mistake 3: Router links without accessible names

<!-- Before: icon-only link with no accessible name -->
<template>
  <router-link to="/profile">
    <UserIcon />
  </router-link>
</template>

<!-- After: icon link with aria-label -->
<template>
  <router-link to="/profile" aria-label="Your profile">
    <UserIcon aria-hidden="true" />
  </router-link>
</template>

When a link contains only an icon or image, screen readers have nothing to announce as the link text. Adding aria-label provides the accessible name, and aria-hidden="true" on the icon prevents it from being announced as an unrecognized image.

Mistake 4: v-for lists without proper markup

<!-- Before: no list semantics -->
<template>
  <div>
    <div v-for="item in items" :key="item.id">
      {{ item.name }}
    </div>
  </div>
</template>

<!-- After: proper list semantics -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

Screen readers announce list semantics (for example, "list, 5 items"), giving users context about the structure and size of the content. A series of <div> elements provides no such context.

Mistake 5: Conditional rendering that breaks focus

<!-- Before: focus is lost when editing state changes -->
<template>
  <div>
    <span v-if="!isEditing">{{ value }}</span>
    <input v-else v-model="value" @blur="isEditing = false" />
    <button @click="isEditing = !isEditing">
      {{ isEditing ? 'Done' : 'Edit' }}
    </button>
  </div>
</template>

<!-- After: focus is managed during state transitions -->
<template>
  <div>
    <span v-if="!isEditing" ref="displayText">{{ value }}</span>
    <input
      v-else
      ref="editInput"
      v-model="value"
      @keydown.enter="stopEditing"
      @keydown.escape="cancelEditing"
    />
    <button ref="editButton" @click="toggleEdit">
      {{ isEditing ? 'Done' : 'Edit' }}
    </button>
  </div>
</template>

<script setup>
import { ref, nextTick } from 'vue'

const isEditing = ref(false)
const value = ref('Sample text')
const editInput = ref(null)
const editButton = ref(null)

async function toggleEdit() {
  isEditing.value = !isEditing.value
  await nextTick()
  if (isEditing.value) {
    editInput.value?.focus()
  } else {
    editButton.value?.focus()
  }
}

async function stopEditing() {
  isEditing.value = false
  await nextTick()
  editButton.value?.focus()
}

async function cancelEditing() {
  isEditing.value = false
  await nextTick()
  editButton.value?.focus()
}
</script>

Mistake 6: Custom select without keyboard support

<!-- Before: mouse-only custom dropdown -->
<template>
  <div class="select" @click="open = !open">
    <span>{{ selected }}</span>
    <div v-if="open" class="dropdown">
      <div
        v-for="option in options"
        :key="option"
        @click="select(option)"
      >
        {{ option }}
      </div>
    </div>
  </div>
</template>

<!-- After: keyboard-accessible custom select -->
<template>
  <div>
    <label :id="labelId">{{ label }}</label>
    <button
      role="combobox"
      :aria-expanded="open"
      :aria-controls="listboxId"
      :aria-labelledby="labelId"
      :aria-activedescendant="activeDescendant"
      @click="open = !open"
      @keydown.down.prevent="openAndFocusFirst"
      @keydown.up.prevent="openAndFocusLast"
      @keydown.escape="open = false"
    >
      {{ selected }}
    </button>
    <ul
      v-if="open"
      :id="listboxId"
      role="listbox"
      :aria-labelledby="labelId"
    >
      <li
        v-for="(option, index) in options"
        :key="option"
        :id="`option-${index}`"
        role="option"
        :aria-selected="option === selected"
        tabindex="-1"
        @click="select(option)"
        @keydown.enter.prevent="select(option)"
        @keydown.down.prevent="focusNext(index)"
        @keydown.up.prevent="focusPrev(index)"
        @keydown.escape="close"
      >
        {{ option }}
      </li>
    </ul>
  </div>
</template>

Custom select components are among the most commonly inaccessible widgets. They need proper ARIA roles, keyboard navigation with arrow keys, and focus management. Consider using Headless UI or Radix Vue instead of building from scratch.

Summary

Building accessible Vue applications requires deliberate effort at every layer: semantic HTML in templates, ARIA attribute binding with v-bind, focus management with template refs and nextTick, route change announcements with Vue Router's afterEach hook, live regions for dynamic content, focus trapping in Teleported modals, and respect for user motion preferences. The Composition API and composables make it possible to encapsulate these patterns for reuse, while libraries like Headless UI and Radix Vue provide battle-tested accessible components. Combine automated testing with axe-core and eslint-plugin-vuejs-accessibility to catch regressions early, and always test with real assistive technology to verify the experience.

Kas sinu veebisait on juurdepaasetav?

Skanni oma veebisaiti tasuta ja saa oma WCAG-skoor monikuminutiga.

Skanni oma saiti tasuta