Accessibility compliance in a modern web app isn't a one-time audit. It's an ongoing practice — patterns in your component library, checks in your CI pipeline, and a shared understanding across your team of what "accessible" means in code. This guide covers practical implementation strategies for React, Next.js, and Vue, along with the tooling that makes compliance maintainable.
Understanding the Scope of WCAG 2.1 AA
WCAG 2.1 at Level AA has 50 success criteria. They span four principles: Perceivable, Operable, Understandable, and Robust (POUR). The most commonly failed ones in modern SPAs:
| Criterion | What it requires | Common failure |
|---|---|---|
| 1.1.1 Non-text Content | Text alternatives for images | Missing alt on meaningful images |
| 1.4.3 Contrast (Minimum) | 4.5:1 for text, 3:1 for large text | Brand colors without contrast checks |
| 1.4.11 Non-text Contrast | 3:1 for UI components | Input borders, icon-only buttons |
| 2.1.1 Keyboard | All functionality via keyboard | Click-handler-only interactions |
| 2.4.3 Focus Order | Logical focus sequence | CSS reordering, dynamic content |
| 2.4.7 Focus Visible | Visible focus indicator | outline: none without replacement |
| 3.1.1 Language of Page | lang attribute on <html> |
Missing lang attribute |
| 4.1.2 Name, Role, Value | Accessible name + role for all UI | Custom components without ARIA |
| 4.1.3 Status Messages | Non-focus status updates reachable | Missing aria-live on notifications |
Setting Up Your Accessibility Toolchain
Linting at write time: eslint-plugin-jsx-a11y
For React and Next.js projects:
npm install --save-dev eslint-plugin-jsx-a11y
// .eslintrc.json
{
"plugins": ["jsx-a11y"],
"extends": ["plugin:jsx-a11y/recommended"]
}
This catches issues at the component level before they reach the browser: missing alt attributes, incorrect ARIA roles, label-input associations, and more. Some rules are in recommended; strict adds additional checks for more rigorous projects.
For Vue, use eslint-plugin-vuejs-accessibility:
npm install --save-dev eslint-plugin-vuejs-accessibility
Browser testing: axe DevTools
The free axe DevTools browser extension (Chrome/Firefox) runs the axe-core engine against your rendered page. It catches roughly 30–40% of WCAG failures that linters can't see — computed contrast, DOM structure issues, missing landmarks.
Run it on every new component in its various states: default, hover, focus, error, empty, loading.
CI integration: axe-core
Add automated accessibility checks to your test suite so regressions are caught before they ship:
With Playwright:
npm install --save-dev @axe-core/playwright
import { checkA11y, injectAxe } from 'axe-playwright';
test('homepage passes WCAG 2.1 AA', async ({ page }) => {
await page.goto('/');
await injectAxe(page);
await checkA11y(page, null, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
});
});
With Jest + Testing Library:
npm install --save-dev jest-axe
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('Button is accessible', async () => {
const { container } = render(<Button>Submit</Button>);
expect(await axe(container)).toHaveNoViolations();
});
React: Accessible Patterns
Form inputs
Always associate labels with inputs. The htmlFor / id pattern is preferred over aria-label for visible form fields:
// Good
function EmailField() {
return (
<div>
<label htmlFor="email">Email address</label>
<input
type="email"
id="email"
aria-describedby={error ? 'email-error' : undefined}
aria-invalid={error ? true : undefined}
/>
{error && (
<p id="email-error" role="alert">
{error}
</p>
)}
</div>
);
}
Don't use placeholder as a substitute for label — placeholders disappear on input, have low contrast by default, and aren't reliably read by all screen readers.
Modal / Dialog
React doesn't provide focus trapping natively. Use a library like @radix-ui/react-dialog, focus-trap-react, or react-aria for modals:
import * as Dialog from '@radix-ui/react-dialog';
function ConfirmDialog({ open, onOpenChange, onConfirm }) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content
className="dialog-content"
aria-describedby="dialog-desc"
>
<Dialog.Title>Delete item</Dialog.Title>
<Dialog.Description id="dialog-desc">
This action cannot be undone.
</Dialog.Description>
<button onClick={() => onOpenChange(false)}>Cancel</button>
<button onClick={onConfirm}>Delete</button>
<Dialog.Close aria-label="Close dialog" />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Radix UI primitives are unstyled, accessible by default, and handle focus management, ARIA attributes, and keyboard interactions.
Focus management after state changes
When a route change or async operation should shift attention:
import { useEffect, useRef } from 'react';
function SearchResults({ results, query }) {
const headingRef = useRef(null);
useEffect(() => {
// After results update, move focus to heading
// so screen readers announce new content
if (results !== null) {
headingRef.current?.focus();
}
}, [results]);
return (
<section>
<h2 ref={headingRef} tabIndex={-1}>
{results?.length ?? 0} results for "{query}"
</h2>
{/* results list */}
</section>
);
}
useReducedMotion
Respect the user's motion preference for animations:
import { useReducedMotion } from 'framer-motion'; // or implement natively
function AnimatedCard({ children }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
>
{children}
</motion.div>
);
}
Or with plain CSS:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Next.js: Specific Considerations
Route announcements
Next.js 13+ (App Router) doesn't automatically announce route changes to screen readers. The Pages Router had next/head and some built-in handling, but App Router requires manual implementation:
// app/layout.tsx
// Move focus to main content on navigation
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function RootLayout({ children }) {
const pathname = usePathname();
const mainRef = useRef(null);
useEffect(() => {
mainRef.current?.focus();
}, [pathname]);
return (
<html lang="en">
<body>
<a href="#main" className="skip-link">Skip to content</a>
<Header />
<main id="main" ref={mainRef} tabIndex={-1}>
{children}
</main>
<Footer />
</body>
</html>
);
}
Images
Always provide alt text. Next.js's <Image> component requires alt as a prop — treat this as an API contract, not a lint warning to suppress:
// Meaningful image
<Image src="/hero.jpg" alt="Student reading on a laptop with FocusFlow enabled" width={800} height={600} />
// Decorative image — empty string tells screen readers to skip it
<Image src="/decoration.png" alt="" width={200} height={100} aria-hidden="true" />
lang attribute
Ensure <html lang="en"> (or the correct BCP 47 language tag) is set in your root layout. This is required for WCAG 3.1.1 and affects screen reader pronunciation.
Vue: Accessible Patterns
Form fields with Composition API
<template>
<div>
<label :for="id">{{ label }}</label>
<input
:id="id"
:type="type"
:aria-invalid="hasError || undefined"
:aria-describedby="hasError ? `${id}-error` : undefined"
/>
<p v-if="hasError" :id="`${id}-error`" role="alert">
{{ errorMessage }}
</p>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps(['id', 'label', 'type', 'error']);
const hasError = computed(() => Boolean(props.error));
const errorMessage = computed(() => props.error);
</script>
Live region for async notifications
<template>
<div>
<button @click="save">Save changes</button>
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{{ statusMessage }}
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const statusMessage = ref('');
async function save() {
await saveData();
statusMessage.value = 'Changes saved successfully.';
setTimeout(() => { statusMessage.value = ''; }, 3000);
}
</script>
The Accessible Component Library Checklist
Every component you ship should be audited against these before it enters the shared library:
| Component | Key checks |
|---|---|
| Button | Keyboard operable, visible focus, accessible name (not just icon) |
| Input / Textarea | <label> associated, error state with aria-invalid + aria-describedby |
| Select / Combobox | Native <select> preferred; custom needs full ARIA + keyboard |
| Checkbox / Radio | Grouped with <fieldset> + <legend>, keyboard operable |
| Modal / Drawer | Focus trap, Escape closes, focus returns to trigger on close |
| Toast / Alert | aria-live region, appropriate urgency level |
| Tabs | Roving tabindex, Arrow keys between tabs, Tab into panel |
| Dropdown Menu | Escape closes, Arrow keys navigate, Tab exits |
| Data Table | scope on headers, caption, sortable columns announced |
| Tooltip | Accessible via keyboard, dismissible with Escape |
Maintaining Compliance Over Time
Accessibility is a regression problem as much as an implementation problem. New components get added, designs change, colors drift. Stay ahead of it:
- Lint at write time — eslint-plugin-jsx-a11y (React) or eslint-plugin-vuejs-accessibility (Vue) in your CI linting step
- Axe in CI —
@axe-core/playwrighton your critical paths - Design system tokens — define accessible color pairs in your theme. If a designer picks
blue-500on white and the design token is flagged as non-compliant, the system catches it early - Periodic manual testing — axe misses ~60-70% of WCAG failures. At least once per release, navigate the entire critical flow with a screen reader and keyboard only
- Accessibility in your definition of done — a feature is not shippable if it fails keyboard navigation or has contrast failures