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:
<!-- ❌ 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:
<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 readersaria-labelledby— Reference element that labels this onearia-describedby— Reference element that describes this onearia-expanded— Whether collapsible element is openaria-hidden— Hide from screen readers (for decorative elements)aria-live— Announce changes dynamicallyrole— Define element purpose when semantic HTML won't work
Keyboard Navigation
Many users navigate by keyboard only. Every interactive element must be keyboard accessible:
<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:
<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:
<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-invalidfor validation states
Images & Media
<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:
npm install -D @axe-core/playwright jest-axe
// __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:
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.