Accessibility Engineering in Vue: Building for Everyone (WCAG 2.1 AA)

Comprehensive guide to building accessible Vue applications. Learn ARIA, keyboard navigation, semantic HTML, color contrast, screen reader support, and automated testing.

Why Accessibility Matters

15% of the global population has disabilities. That's 1 billion people.

For businesses:

  • Legal: WCAG compliance required in many jurisdictions (US, UK, EU)
  • Market: Accessible sites reach 1 billion+ users
  • SEO: Google rewards accessible sites
  • UX: Good accessibility is good UX for everyone (captions help in noisy environments)

Accessibility isn't a feature. It's a requirement.

Web Content Accessibility Guidelines (WCAG) 2.1

The standard has three levels:

  • A: Minimum compliance
  • AA: Recommended (what most sites aim for)
  • AAA: Enhanced (nice to have)

We'll focus on AA, the legal standard in most places.

Semantic HTML

The foundation of accessibility:

vue
<!-- ❌ Bad: Non-semantic HTML -->
<div class="header">
  <div class="heading">My Blog</div>
  <div class="navigation">
    <div class="nav-item"><a href="/">Home</a></div>
    <div class="nav-item"><a href="/blog">Blog</a></div>
  </div>
</div>

<div class="main">
  <div class="article">
    <div class="title">Post Title</div>
    <div class="content">Post content...</div>
  </div>
</div>

<!-- ✅ Good: Semantic HTML -->
<header>
  <h1>My Blog</h1>
  <nav>
    <ul>
      <li><a href="/">Home</a></li>
      <li><a href="/blog">Blog</a></li>
    </ul>
  </nav>
</header>

<main>
  <article>
    <h2>Post Title</h2>
    <p>Post content...</p>
  </article>
</main>

Semantic elements:

  • <header>, <nav>, <main>, <article>, <section>, <footer>
  • <h1> - <h6> for headings (never skip levels)
  • <button> for interactive elements
  • <label> for form inputs
  • <table> for tabular data (with <thead>, <tbody>)

ARIA (Accessible Rich Internet Applications)

Use ARIA when HTML alone can't describe the UI:

vue
<script setup lang="ts">
const isOpen = ref(false)
const buttonRef = ref<HTMLButtonElement>()
const menuRef = ref<HTMLUListElement>()

const toggleMenu = () => {
  isOpen.value = !isOpen.value
  if (isOpen.value) {
    menuRef.value?.focus()
  }
}

const closeMenu = () => {
  isOpen.value = false
  buttonRef.value?.focus()
}
</script>

<template>
  <!-- ARIA roles, states, and properties -->
  <div class="dropdown">
    <button
      ref="buttonRef"
      :aria-expanded="isOpen"
      aria-haspopup="menu"
      @click="toggleMenu"
    >
      Menu
    </button>

    <ul
      v-if="isOpen"
      ref="menuRef"
      role="menu"
      class="dropdown-menu"
      @keydown.esc="closeMenu"
    >
      <li role="none">
        <a role="menuitem" href="#home" @click="closeMenu">Home</a>
      </li>
      <li role="none">
        <a role="menuitem" href="#about" @click="closeMenu">About</a>
      </li>
      <li role="separator" aria-label="Section divider" />
      <li role="none">
        <a role="menuitem" href="#contact" @click="closeMenu">Contact</a>
      </li>
    </ul>
  </div>
</template>

Key ARIA attributes:

  • aria-label — Label for screen readers
  • aria-labelledby — Reference element that labels this one
  • aria-describedby — Reference element that describes this one
  • aria-expanded — Whether collapsible element is open
  • aria-hidden — Hide from screen readers (for decorative elements)
  • aria-live — Announce changes dynamically
  • role — Define element purpose when semantic HTML won't work

Keyboard Navigation

Many users navigate by keyboard only. Every interactive element must be keyboard accessible:

vue
<script setup lang="ts">
// Trap focus in modal
const trapFocus = (event: KeyboardEvent) => {
  if (event.key !== 'Tab') return

  const focusableElements = document.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  )
  
  const firstElement = focusableElements[0] as HTMLElement
  const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement

  if (event.shiftKey && document.activeElement === firstElement) {
    event.preventDefault()
    lastElement.focus()
  } else if (!event.shiftKey && document.activeElement === lastElement) {
    event.preventDefault()
    firstElement.focus()
  }
}
</script>

<template>
  <!-- Make elements focusable -->
  <div
    role="dialog"
    @keydown="trapFocus"
    @keydown.escape="closeModal"
  >
    <h2 id="modal-title">Confirm Action</h2>
    
    <!-- Visible focus indicator -->
    <button @click="confirm" class="focus-visible:ring-2">
      Confirm
    </button>
    
    <button @click="cancel" class="focus-visible:ring-2">
      Cancel
    </button>
  </div>
</template>

<style scoped>
/* Always show focus indicator -->
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Don't hide focus on keyboard users -->
button:focus-visible {
  outline: inherit;
}

/* But hide for mouse users if desired */
button:focus:not(:focus-visible) {
  outline: none;
}
</style>

Color Contrast

Text must have sufficient contrast ratio:

vue
<style scoped>
/* WCAG AA requires:
   - Normal text: 4.5:1
   - Large text: 3:1
*/

.text-primary {
  color: #000;
  background: #fff;
  /* 21:1 contrast ✅ */
}

.text-secondary {
  color: #555;
  background: #fff;
  /* 8.59:1 contrast ✅ */
}

.text-tertiary {
  color: #999;
  background: #fff;
  /* 3.74:1 contrast ❌ */
}

/* Test with: https://webaim.org/resources/contrastchecker/ */
</style>

Use tools to check:

  • WebAIM Contrast Checker
  • Accessible Colors
  • Chrome DevTools (automatic detection)

Form Accessibility

Forms are a common accessibility fail point:

vue
<template>
  <form @submit.prevent="submit">
    <!-- ✅ Properly labeled input -->
    <div class="form-group">
      <label for="email">Email Address</label>
      <input
        id="email"
        type="email"
        required
        aria-describedby="email-hint"
      />
      <small id="email-hint">We'll never share your email</small>
    </div>

    <!-- ✅ Error messages linked to input -->
    <div class="form-group">
      <label for="password">Password</label>
      <input
        id="password"
        type="password"
        required
        aria-invalid="password.invalid"
        :aria-describedby="password.invalid ? 'password-error' : undefined"
      />
      <div v-if="password.invalid" id="password-error" role="alert">
        Password must be at least 8 characters
      </div>
    </div>

    <!-- ✅ Radio buttons with fieldset -->
    <fieldset>
      <legend>Select your role</legend>
      <div>
        <input id="admin" type="radio" name="role" value="admin" />
        <label for="admin">Administrator</label>
      </div>
      <div>
        <input id="user" type="radio" name="role" value="user" />
        <label for="user">User</label>
      </div>
    </fieldset>

    <button type="submit">Submit</button>
  </form>
</template>

Key form patterns:

  • Every input has a <label>
  • Labels are connected with for="id"
  • Error messages use aria-describedby
  • Fieldset groups related inputs
  • Use aria-invalid for validation states

Images & Media

vue
<template>
  <!-- ✅ Descriptive alt text -->
  <img
    src="hero.jpg"
    alt="Sunset over mountains with golden light reflecting on water"
  />

  <!-- ❌ Vague alt text -->
  <img src="photo.jpg" alt="Photo" />

  <!-- Icons with labels -->
  <button aria-label="Close menu">
    <svg><!-- close icon --></svg>
  </button>

  <!-- Videos with captions -->
  <video controls>
    <source src="video.mp4" type="video/mp4" />
    <track kind="captions" src="captions.vtt" srclang="en" />
  </video>
</template>

Alt text guidelines:

  • Describe the content, not "image of"
  • Decorative images get alt=""
  • Icons need aria-label if no visible text
  • Keep under 125 characters

Automated Testing

Use axe for automated accessibility testing:

bash
npm install -D @axe-core/playwright jest-axe
typescript
// __tests__/accessibility.spec.ts
import { test, expect } from '@playwright/test'
import { injectAxe, checkA11y } from 'axe-playwright'

test('homepage is accessible', async ({ page }) => {
  await page.goto('http://localhost:3000')
  await injectAxe(page)
  await checkA11y(page)
})

Jest testing:

typescript
import { render } from '@vue/test-utils'
import { axe, toHaveNoViolations } from 'jest-axe'
import Button from './Button.vue'

expect.extend(toHaveNoViolations)

it('should not have accessibility violations', async () => {
  const { container } = render(Button)
  const results = await axe(container)
  expect(results).toHaveNoViolations()
})

Accessibility Checklist

Before shipping:

  • Semantic HTML — use proper elements, not divs
  • Color contrast — 4.5:1 for normal text
  • Keyboard navigation — every interactive element accessible
  • Focus visible — clear focus indicator visible
  • Forms labeled — all inputs have labels
  • Images have alt text — descriptive, not vague
  • Headings logical — h1 → h2 → h3 (no skipping)
  • ARIA appropriate — only when necessary
  • No flash — no content flashing > 3 times/second
  • Responsive text — readable at 200% zoom
  • Mobile accessible — touch targets 48px minimum
  • Automated tests pass — axe, jest-axe

Real Impact

After implementing accessibility:

  • Accessibility score: 40 → 92/100 (Lighthouse)
  • Users with assistive tech: +8% traffic
  • SEO improvement: +15% in search ranking
  • General UX improvement: better for everyone

Accessibility isn't a nice-to-have. It's engineering excellence.