Back to Blog

Web Accessibility (WCAG 2.2): Practical Implementation for React and Next.js

Implement WCAG 2.2 accessibility in React and Next.js — ARIA roles, keyboard navigation, focus management, screen reader testing, and automated auditing with ax

Viprasol Tech Team
April 12, 2026
12 min read

Web Accessibility (WCAG 2.2): Practical Implementation for React and Next.js

Accessibility is rarely the top priority until it's legally required. In 2026, ADA Title III lawsuits targeting inaccessible websites are common — over 4,600 were filed in the US in 2024 alone, most targeting e-commerce and SaaS products. Beyond legal risk, accessible applications have higher conversion rates (the estimated disabled user population with purchasing power exceeds $490 billion annually) and better SEO (semantic HTML and ARIA labels improve crawlability).

This guide covers WCAG 2.2 implementation for React applications — the specific patterns, code, and testing approach that gets you to AA compliance.


WCAG 2.2 Levels and What's Required

WCAG has three conformance levels: A (minimum), AA (standard requirement for most legal compliance), and AAA (aspirational). Most businesses target AA.

New in WCAG 2.2 (beyond 2.1):

CriterionLevelWhat Changed
2.4.11 Focus Not Obscured (Minimum)AAFocused element must be at least partially visible
2.4.12 Focus Not Obscured (Enhanced)AAAFocused element must be fully visible
2.4.13 Focus AppearanceAAAFocus indicator must be 2px solid with minimum contrast
2.5.7 Dragging MovementsAAAll drag actions need a pointer alternative
2.5.8 Target Size (Minimum)AAInteractive targets ≥ 24×24 CSS pixels
3.2.6 Consistent HelpAHelp mechanisms in consistent location
3.3.7 Redundant EntryADon't ask for same info twice in same session
3.3.8 Accessible AuthenticationAANo cognitive tests (CAPTCHA) without alternative

Semantic HTML: The Foundation

Most accessibility issues start with non-semantic HTML. Before adding ARIA, use the right HTML elements:

// ❌ Non-semantic — screen readers can't interpret structure
<div onClick={handleSubmit}>Submit</div>
<div className="header">...</div>
<div className="nav">...</div>
<div className="article-content">...</div>

// ✅ Semantic — structure is machine-readable
<button type="submit" onClick={handleSubmit}>Submit</button>
<header>...</header>
<nav aria-label="Main navigation">...</nav>
<article>...</article>

// ✅ Landmark regions — screen reader users navigate by landmarks
<header role="banner">
  <nav aria-label="Primary navigation">...</nav>
</header>
<main id="main-content">  {/* Skip link target */}
  <article aria-labelledby="article-heading">
    <h1 id="article-heading">Article Title</h1>
  </article>
</main>
<aside aria-label="Related content">...</aside>
<footer role="contentinfo">...</footer>

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Skip Navigation Links

Screen reader and keyboard users must be able to skip repetitive navigation:

// components/SkipNav.tsx
export function SkipNav() {
  return (
    <a
      href="#main-content"
      className={`
        absolute left-0 top-0 z-50 -translate-y-full
        bg-blue-600 px-4 py-2 text-white
        focus:translate-y-0 transition-transform
        focus:outline-none focus:ring-2 focus:ring-white
      `}
    >
      Skip to main content
    </a>
  );
}

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SkipNav />
        <header>...</header>
        <main id="main-content" tabIndex={-1}>
          {children}
        </main>
      </body>
    </html>
  );
}

The tabIndex={-1} on <main> allows the skip link to focus it programmatically without putting it in the natural tab order.


Focus Management

Single-page applications break browser focus management — when content changes, focus stays where it was (often on a now-gone element).

// hooks/useFocusOnMount.ts
import { useEffect, useRef } from 'react';

export function useFocusOnMount<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  useEffect(() => {
    ref.current?.focus();
  }, []);
  return ref;
}

// When navigating to a new page/view, focus the heading
export default function ProductPage({ product }: { product: Product }) {
  const headingRef = useFocusOnMount<HTMLHeadingElement>();
  return (
    <article>
      <h1 ref={headingRef} tabIndex={-1} className="focus:outline-none">
        {product.name}
      </h1>
      ...
    </article>
  );
}

// Modal focus trap
import { useEffect } from 'react';

export function useFocusTrap(containerRef: React.RefObject<HTMLElement>, isActive: boolean) {
  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const focusableSelectors = [
      'button:not([disabled])',
      'a[href]',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])',
    ].join(', ');

    const container = containerRef.current;
    const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelectors);
    const firstEl = focusableElements[0];
    const lastEl = focusableElements[focusableElements.length - 1];

    // Focus first element when modal opens
    firstEl?.focus();

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        if (document.activeElement === firstEl) {
          e.preventDefault();
          lastEl?.focus();
        }
      } else {
        if (document.activeElement === lastEl) {
          e.preventDefault();
          firstEl?.focus();
        }
      }
    }

    container.addEventListener('keydown', handleKeyDown);
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, [isActive, containerRef]);
}

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

ARIA Patterns for Common Components

Accessible modal dialog:

// components/Modal.tsx
import { useEffect, useRef } from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useFocusTrap(containerRef, isOpen);

  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement;
    } else {
      // Restore focus when modal closes
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    function handleEscape(e: KeyboardEvent) {
      if (e.key === 'Escape') onClose();
    }
    if (isOpen) {
      document.addEventListener('keydown', handleEscape);
      document.body.style.overflow = 'hidden';  // Prevent background scroll
    }
    return () => {
      document.removeEventListener('keydown', handleEscape);
      document.body.style.overflow = '';
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <>
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-black/50 z-40"
        onClick={onClose}
        aria-hidden="true"
      />
      {/* Modal */}
      <div
        ref={containerRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="fixed inset-0 z-50 flex items-center justify-center p-4"
      >
        <div className="bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
          <div className="flex justify-between items-center mb-4">
            <h2 id="modal-title" className="text-xl font-semibold">{title}</h2>
            <button
              onClick={onClose}
              aria-label="Close dialog"
              className="p-2 hover:bg-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
            >
              <span aria-hidden="true"></span>
            </button>
          </div>
          {children}
        </div>
      </div>
    </>
  );
}

Accessible accordion:

// components/Accordion.tsx
import { useState } from 'react';

interface AccordionItem {
  id: string;
  title: string;
  content: React.ReactNode;
}

export function Accordion({ items }: { items: AccordionItem[] }) {
  const [openItems, setOpenItems] = useState<Set<string>>(new Set());

  const toggle = (id: string) => {
    setOpenItems(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  return (
    <div>
      {items.map((item) => {
        const isOpen = openItems.has(item.id);
        const contentId = `accordion-content-${item.id}`;
        const headerId = `accordion-header-${item.id}`;
        return (
          <div key={item.id} className="border-b">
            <h3>
              <button
                id={headerId}
                aria-expanded={isOpen}
                aria-controls={contentId}
                onClick={() => toggle(item.id)}
                className="w-full text-left px-4 py-3 flex justify-between items-center focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
              >
                {item.title}
                <span aria-hidden="true">{isOpen ? '▲' : '▼'}</span>
              </button>
            </h3>
            <div
              id={contentId}
              role="region"
              aria-labelledby={headerId}
              hidden={!isOpen}
              className="px-4 py-3"
            >
              {item.content}
            </div>
          </div>
        );
      })}
    </div>
  );
}

Color Contrast

WCAG AA requires:

  • Normal text (< 18pt): contrast ratio ≥ 4.5:1
  • Large text (≥ 18pt or 14pt bold): contrast ratio ≥ 3:1
  • UI components and graphics: ≥ 3:1
// Check in Tailwind — use a tool like https://webaim.org/resources/contrastchecker/

// ❌ Common failure: gray text on white
<p className="text-gray-400">This fails — #9CA3AF on white = 2.86:1</p>

// ✅ Pass for normal text
<p className="text-gray-600">This passes — #4B5563 on white = 7.0:1</p>

// ❌ Common failure: blue button with white text
<button className="bg-blue-400 text-white">Fails — 2.59:1</button>

// ✅ Pass
<button className="bg-blue-600 text-white">Passes — 4.57:1</button>

Automated Testing

Automated tools catch ~30–40% of WCAG issues. Use them in CI as a baseline, not a replacement for manual testing.

axe-core in Playwright:

// tests/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Accessibility', () => {
  test('home page has no WCAG violations', async ({ page }) => {
    await page.goto('/');
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test('product page is accessible', async ({ page }) => {
    await page.goto('/products/example-product');
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .exclude('.third-party-widget')  // Exclude known third-party issues
      .analyze();

    if (results.violations.length > 0) {
      console.log('Violations:', JSON.stringify(results.violations, null, 2));
    }
    expect(results.violations).toEqual([]);
  });

  test('modal is accessible when open', async ({ page }) => {
    await page.goto('/pricing');
    await page.click('[data-testid="open-contact-modal"]');
    await page.waitForSelector('[role="dialog"]');

    const results = await new AxeBuilder({ page })
      .include('[role="dialog"]')
      .withTags(['wcag2a', 'wcag2aa'])
      .analyze();

    expect(results.violations).toEqual([]);
  });
});

Screen reader testing (manual — can't be automated): Test with NVDA + Firefox (Windows) and VoiceOver + Safari (Mac). Walk through key user journeys — sign up, checkout, account management — listening only to the screen reader with the monitor off.


Target Size (WCAG 2.5.8 — New in 2.2)

Interactive elements must be at least 24×24 CSS pixels:

// ❌ Icon buttons often fail target size
<button className="p-1">
  <ChevronIcon className="w-4 h-4" />  {/* Total size: 24px — borderline */}
</button>

// ✅ Use padding to ensure minimum target size
<button className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
  <ChevronIcon className="w-4 h-4" aria-hidden="true" />
  <span className="sr-only">Next page</span>
</button>

The sr-only class (Tailwind) hides text visually while keeping it available to screen readers — the correct approach for icon-only buttons.


Accessibility Audit Cost

ScopeMethodCostFindings
Automated scan onlyaxe-core / Lighthouse$0 (tool cost)30–40% of issues
Manual expert audit (10 pages)External auditor$3,000–8,00080–95% of issues
Full VPAT (for enterprise sales)Certified auditor$8,000–25,000Formal documentation
Ongoing monitoringaxe Monitor$100–500/moRegression detection
Remediation (per page)Developer time$500–3,000/pageDepends on severity

For SaaS products targeting enterprise customers, a VPAT (Voluntary Product Accessibility Template) is increasingly required in procurement. Enterprises with government funding often require WCAG 2.1 AA or Section 508 compliance.


Working With Viprasol

We build accessibility into products from the start — semantic HTML, keyboard navigation, ARIA patterns, and automated axe-core tests in CI. For existing products, we conduct accessibility audits and remediation sprints that bring applications to WCAG 2.2 AA compliance.

Talk to our team about accessibility implementation or auditing.


See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.