Back to Blog

Web Accessibility: WCAG 2.2, ARIA, Keyboard Navigation, and Screen Reader Testing

Build accessible web applications in 2026 — WCAG 2.2 success criteria, ARIA roles and labels, keyboard navigation patterns, focus management, color contrast, sc

Viprasol Tech Team
June 20, 2026
12 min read

Web Accessibility: WCAG 2.2, ARIA, Keyboard Navigation, and Screen Reader Testing

Web accessibility means building products that work for everyone — including users with visual, motor, auditory, and cognitive disabilities. In most markets, it's also a legal requirement: WCAG 2.1 AA is referenced by ADA, the EU Web Accessibility Directive, and many national standards.

The good news: accessible code is usually better code. Keyboard navigation, semantic HTML, and clear labels improve the experience for all users, not just those using assistive technology.


WCAG 2.2 Core Principles

WCAG (Web Content Accessibility Guidelines) organizes requirements around four principles:

PrincipleWhat It MeansKey Success Criteria
PerceivableInformation must be presentable to all sensesAlt text, captions, color contrast (4.5:1 AA), no color-only information
OperableInterface must be operable by all input methodsKeyboard accessible, no seizure-inducing content, focus visible, 2.4.11 Focus Appearance (new in 2.2)
UnderstandableContent and UI must be understandableLanguage declared, error identification, labels for inputs
RobustContent must work with current and future assistive techValid HTML, ARIA used correctly, status messages

WCAG 2.2 new requirements (added over 2.1):

  • 2.4.11 Focus Appearance (AA): Focus indicator must have area ≥ perimeter of the component, with 3:1 contrast ratio
  • 2.5.7 Dragging Movements: Any drag action must have a single-pointer alternative
  • 2.5.8 Target Size (AA): Interactive targets must be at least 24×24 CSS pixels
  • 3.2.6 Consistent Help: Help mechanisms must be in consistent locations

Semantic HTML First

The most impactful accessibility improvement is using semantic HTML. Screenreaders understand <button>, <nav>, <main>, <h1>-<h6> inherently:

// ❌ Inaccessible: div soup with click handlers
function BadNav() {
  return (
    <div className="nav">
      <div onClick={() => navigate('/home')} className="nav-item">Home</div>
      <div onClick={() => navigate('/orders')} className="nav-item">Orders</div>
    </div>
  );
}

// ✅ Semantic HTML: correct elements, keyboard accessible by default
function GoodNav() {
  return (
    <nav aria-label="Main navigation">
      <ul>
        <li><a href="/home">Home</a></li>
        <li><a href="/orders">Orders</a></li>
      </ul>
    </nav>
  );
}

// ❌ Custom button: loses keyboard support, no role
<div className="btn" onClick={handleClick}>Submit</div>

// ✅ Real button: keyboard accessible, correct role, focus management built-in
<button type="submit" onClick={handleClick}>Submit</button>

🌐 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

ARIA: When and How

ARIA (Accessible Rich Internet Applications) supplements HTML semantics for complex widgets. The rule: use native HTML elements first; use ARIA only when there's no native equivalent.

// ✅ Dialog/Modal with correct ARIA
function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const titleId = useId();

  useEffect(() => {
    if (!isOpen) return;
    // Trap focus inside modal
    const firstFocusable = document.querySelector<HTMLElement>('[data-modal] button, [data-modal] a, [data-modal] input');
    firstFocusable?.focus();
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby={titleId}
      data-modal
    >
      <h2 id={titleId}>{title}</h2>
      <button onClick={onClose} aria-label="Close dialog"></button>
      {children}
    </div>
  );
}

// ✅ Loading state with live region
function DataTable({ isLoading, data }: TableProps) {
  return (
    <div>
      {/* aria-live announces changes to screen readers */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {isLoading ? 'Loading data...' : `${data.length} results loaded`}
      </div>

      {isLoading ? (
        <div aria-busy="true">Loading...</div>
      ) : (
        <table>
          <caption>Orders — {data.length} results</caption>
          {/* ... */}
        </table>
      )}
    </div>
  );
}

// ✅ Form error announcement
function FormField({ label, error, id, ...props }: FieldProps) {
  const errorId = `${id}-error`;

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-describedby={error ? errorId : undefined}
        aria-invalid={error ? 'true' : undefined}
        {...props}
      />
      {error && (
        <span id={errorId} role="alert">
          {error}
        </span>
      )}
    </div>
  );
}

Keyboard Navigation

Every interactive element must be reachable and usable by keyboard:

// ✅ Dropdown menu with keyboard support
function DropdownMenu({ trigger, items }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const menuRef = useRef<HTMLUListElement>(null);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(i => Math.min(i + 1, items.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (activeIndex >= 0) items[activeIndex].onClick();
        setIsOpen(false);
        break;
      case 'Escape':
        setIsOpen(false);
        break;
      case 'Home':
        e.preventDefault();
        setActiveIndex(0);
        break;
      case 'End':
        e.preventDefault();
        setActiveIndex(items.length - 1);
        break;
    }
  };

  return (
    <div>
      <button
        onClick={() => setIsOpen(!isOpen)}
        aria-haspopup="menu"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
      >
        {trigger}
      </button>

      {isOpen && (
        <ul
          id="dropdown-menu"
          role="menu"
          ref={menuRef}
          onKeyDown={handleKeyDown}
        >
          {items.map((item, index) => (
            <li key={item.id} role="none">
              <button
                role="menuitem"
                tabIndex={activeIndex === index ? 0 : -1}
                onClick={item.onClick}
                aria-current={activeIndex === index ? 'true' : undefined}
              >
                {item.label}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Focus trap for modals:

// lib/use-focus-trap.ts
export function useFocusTrap(containerRef: React.RefObject<HTMLElement>, isActive: boolean) {
  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const container = containerRef.current;
    const focusableSelectors = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
    const focusables = Array.from(container.querySelectorAll<HTMLElement>(focusableSelectors));

    if (!focusables.length) return;
    focusables[0].focus();

    const handleTab = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      const first = focusables[0];
      const last = focusables[focusables.length - 1];

      if (e.shiftKey) {
        if (document.activeElement === first) {
          e.preventDefault();
          last.focus();
        }
      } else {
        if (document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    };

    document.addEventListener('keydown', handleTab);
    return () => document.removeEventListener('keydown', handleTab);
  }, [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

Color Contrast

WCAG AA requires 4.5:1 contrast ratio for normal text, 3:1 for large text (18px+ or 14px+ bold):

/* ✅ Check contrast in your design system tokens */
:root {
  /* Text on white background */
  --text-primary: #111827;      /* 19.1:1 ✅ */
  --text-secondary: #374151;    /* 10.7:1 ✅ */
  --text-muted: #6B7280;        /* 4.6:1 ✅ (barely passes) */
  --text-disabled: #9CA3AF;     /* 2.9:1 ❌ fails AA — use for non-essential only */

  /* Interactive elements */
  --primary-500: #3B82F6;       /* 3.1:1 on white ❌ for text, OK for large targets */
  --primary-600: #2563EB;       /* 4.9:1 on white ✅ */
  --primary-700: #1D4ED8;       /* 6.9:1 on white ✅ */
}

Automated contrast checking in your build:

// scripts/check-contrast.ts — run in CI
import { colord, extend } from 'colord';
import a11yPlugin from 'colord/plugins/a11y';

extend([a11yPlugin]);

const colorPairs = [
  { bg: '#FFFFFF', fg: '#6B7280', element: 'muted text' },
  { bg: '#3B82F6', fg: '#FFFFFF', element: 'primary button text' },
  { bg: '#FEF3C7', fg: '#92400E', element: 'warning badge' },
];

let allPassed = true;

for (const { bg, fg, element } of colorPairs) {
  const ratio = colord(fg).contrast(colord(bg));
  const passes = ratio >= 4.5;
  if (!passes) {
    console.error(`❌ ${element}: ${ratio.toFixed(2)}:1 (requires 4.5:1)`);
    allPassed = false;
  } else {
    console.log(`✅ ${element}: ${ratio.toFixed(2)}:1`);
  }
}

if (!allPassed) process.exit(1);

Automated Testing

# Install axe-core for automated accessibility testing
pnpm add -D @axe-core/playwright

# Playwright accessibility test
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

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

  expect(results.violations).toHaveLength(0);
});

test('order form is accessible', async ({ page }) => {
  await page.goto('/orders/new');
  const results = await new AxeBuilder({ page })
    .include('#order-form')
    .analyze();

  expect(results.violations).toHaveLength(0);
});
# Manual screen reader testing setup
# macOS: VoiceOver — Cmd+F5 to enable
# Windows: NVDA — free download from nvaccess.org
# iOS: VoiceOver — Settings > Accessibility > VoiceOver
# Android: TalkBack — Settings > Accessibility > TalkBack

# Screen reader testing checklist:
# □ Can you navigate all content with Tab key alone?
# □ Are form labels read correctly?
# □ Are error messages announced when they appear?
# □ Is modal focus trapped correctly?
# □ Are loading states announced?
# □ Do images have meaningful alt text?

Working With Viprasol

We build accessible web applications — WCAG 2.2 AA compliance, semantic HTML, ARIA implementation, keyboard navigation, automated accessibility testing in CI, and accessibility audits for existing products.

Talk to our team about accessibility and inclusive product development.


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.