Skip to main content
Back to Blog
Developer Guide June 18, 2026

Handling WCAG Compliance in Modern Web Apps (React, Next.js, Vue)

Handling WCAG Compliance in Modern Web Apps (React, Next.js, Vue)

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:

  1. Lint at write time — eslint-plugin-jsx-a11y (React) or eslint-plugin-vuejs-accessibility (Vue) in your CI linting step
  2. Axe in CI@axe-core/playwright on your critical paths
  3. Design system tokens — define accessible color pairs in your theme. If a designer picks blue-500 on white and the design token is flagged as non-compliant, the system catches it early
  4. 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
  5. Accessibility in your definition of done — a feature is not shippable if it fails keyboard navigation or has contrast failures