Back to Blog

Accessibility Testing 2026: axe-core + Playwright (Official Docs)

Build accessible web applications: axe-core automated testing, WCAG 2.2 compliance, Playwright a11y integration, screen reader testing with NVDA/VoiceOver, and CI accessibility gates.

Viprasol Tech Team
12 min read
Updated 2026

Accessibility Testing in 2026: axe-core, Automated CI, and Screen Reader Testing

Quick answer. Accessibility testing combines automated and manual checks. Run axe-core (via jest-axe or Playwright) in CI to catch the issues machines can detect, then add manual keyboard-navigation and screen-reader testing (VoiceOver, NVDA) for the rest. Because accessibility is a legal requirement under the ADA, Section 508, and the EU EAA, automated CI checks plus periodic manual audits are the reliable baseline.

Web accessibility is a legal requirement in the US (ADA, Section 508), EU (EAA), and dozens of other jurisdictions โ€” and lawsuits have been increasing year-over-year. Beyond compliance, accessible applications work better for everyone: keyboard navigation helps power users, high-contrast modes help users in bright environments, and clear error messages help everyone.

Automated tools catch about 30% of WCAG violations. The remaining 70% require manual testing โ€” keyboard navigation, screen reader testing, and cognitive accessibility review. This post covers both: automated axe-core integration in CI, plus a practical manual testing workflow.


WCAG 2.2 Quick Reference

LevelCriteriaRequirement
A (minimum)30 criteriaMust meet for basic accessibility
AA (standard)+20 criteriaTarget for most applications
AAA (enhanced)+28 criteriaAspirational; not required for most

Most commonly failed WCAG 2.2 criteria (AA):

  1. 1.1.1 โ€” Non-text content: images missing alt text
  2. 1.3.1 โ€” Info and relationships: form labels not associated with inputs
  3. 1.4.3 โ€” Contrast (minimum): 4.5:1 for normal text, 3:1 for large text
  4. 2.1.1 โ€” Keyboard: all functionality operable via keyboard
  5. 2.4.7 โ€” Focus visible: keyboard focus indicator visible
  6. 4.1.2 โ€” Name, role, value: interactive elements have accessible names

axe-core: Automated Accessibility Testing

With Jest + RNTL (React Components)

npm install -D @axe-core/react jest-axe
// src/__tests__/accessibility.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { LoginForm } from '@/components/LoginForm';
import { ProductCard } from '@/components/ProductCard';
import { Navigation } from '@/components/Navigation';

expect.extend(toHaveNoViolations);

describe('Accessibility: LoginForm', () => {
  it('has no WCAG violations', async () => {
    const { container } = render(<LoginForm onSubmit={jest.fn()} />);
    const results = await axe(container, {
      rules: {
        // Optionally disable rules you're intentionally deferring
        'color-contrast': { enabled: true },
        'region': { enabled: false },  // Top-level landmarks not required in components
      },
    });
    expect(results).toHaveNoViolations();
  });
});

describe('Accessibility: ProductCard', () => {
  it('has no WCAG violations', async () => {
    const { container } = render(
      <ProductCard
        name="Test Product"
        price={999}
        image="/test.jpg"
        onAddToCart={jest.fn()}
      />
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('image has meaningful alt text', () => {
    const { getByRole } = render(
      <ProductCard name="Blue Widget" price={999} image="/widget.jpg" onAddToCart={jest.fn()} />
    );
    const img = getByRole('img');
    expect(img).toHaveAttribute('alt', expect.stringMatching(/blue widget/i));
  });
});

describe('Accessibility: Navigation', () => {
  it('has no WCAG violations', async () => {
    const { container } = render(<Navigation />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('has a landmark nav element', () => {
    const { getByRole } = render(<Navigation />);
    expect(getByRole('navigation')).toBeInTheDocument();
  });
});

With Playwright (Full Page E2E)

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

test.describe('Full Page Accessibility', () => {
  test('homepage has no WCAG AA violations', async ({ page }) => {
    await page.goto('/');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
      .exclude('#cookie-banner')  // Known issue โ€” tracked separately
      .analyze();

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

  test('login page has no violations', async ({ page }) => {
    await page.goto('/login');

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

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

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

  test('dashboard has no violations after authentication', async ({ page }) => {
    // Log in first
    await page.goto('/login');
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    await page.waitForURL('/dashboard');

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

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

// Snapshot violations for regression tracking
test('capture accessibility snapshot', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();

  // Write snapshot to file for diff tracking
  const fs = await import('fs/promises');
  await fs.writeFile(
    'tests/accessibility/snapshots/homepage.json',
    JSON.stringify(results.violations, null, 2),
  );
});

CI Integration

# .github/workflows/accessibility.yml
name: Accessibility Testing

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  a11y-unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - name: Run axe unit tests
        run: npx jest --testPathPattern="accessibility" --passWithNoTests

  a11y-e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Build app
        run: npm run build
      - name: Start app
        run: npm start &
      - name: Wait for app
        run: npx wait-on http://localhost:3000
      - name: Run Playwright a11y tests
        run: npx playwright test tests/accessibility/
      - name: Upload violation report
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: a11y-violations
          path: tests/accessibility/snapshots/

๐ŸŒ 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 1000+ 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

Common React Accessibility Patterns

Accessible Form Fields

// โœ… Properly associated label, error, and description
function FormField({
  id,
  label,
  error,
  hint,
  required,
  ...inputProps
}: FormFieldProps) {
  const errorId = `${id}-error`;
  const hintId  = `${id}-hint`;
  const ariaDesc = [hint && hintId, error && errorId].filter(Boolean).join(' ');

  return (
    <div>
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>

      {hint && (
        <p id={hintId} className="text-sm text-gray-500">
          {hint}
        </p>
      )}

      <input
        id={id}
        aria-describedby={ariaDesc || undefined}
        aria-invalid={error ? 'true' : undefined}
        aria-required={required}
        {...inputProps}
      />

      {error && (
        <p id={errorId} role="alert" className="text-sm text-red-600">
          {error}
        </p>
      )}
    </div>
  );
}

Accessible Modal

// โœ… Focus trap, aria attributes, escape key, scroll lock
import { useEffect, useRef } from 'react';

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

  useEffect(() => {
    if (isOpen) {
      // Store the previously focused element
      previousFocusRef.current = document.activeElement as HTMLElement;

      // Move focus into the modal
      dialogRef.current?.focus();

      // Lock scroll
      document.body.style.overflow = 'hidden';
    } else {
      // Restore focus and scroll
      document.body.style.overflow = '';
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  // Close on Escape key
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape' && isOpen) onClose();
    }
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <>
      {/* Backdrop โ€” click to close */}
      <div
        className="fixed inset-0 bg-black/50"
        aria-hidden="true"
        onClick={onClose}
      />

      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}   // Makes div focusable for initial focus
        className="fixed inset-0 z-50 flex items-center justify-center p-4"
      >
        <div className="bg-white rounded-lg p-6 max-w-md w-full">
          <h2 id="modal-title">{title}</h2>
          {children}
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="absolute top-4 right-4"
          >
            โœ•
          </button>
        </div>
      </div>
    </>
  );
}

Manual Testing Checklist

Automated tools miss ~70% of violations. Manual testing covers what automation can't:

accessibility - Accessibility Testing 2026: axe-core + Playwright (Official Docs)

๐Ÿš€ 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

Keyboard Navigation

  • Tab through entire page โ€” focus order is logical
  • All interactive elements reachable by Tab
  • Focus indicator clearly visible on all elements
  • No keyboard traps (can always Tab out of any component)
  • Dropdowns, modals close with Escape
  • Custom widgets (date pickers, carousels) follow ARIA patterns

Screen Reader (NVDA + Chrome, VoiceOver + Safari)

  • Page title announced on load
  • Headings create logical document outline (h1 โ†’ h2 โ†’ h3)
  • Images have meaningful alt text (decorative images: alt="")
  • Form fields announced with label, type, and required status
  • Error messages announced (role="alert" or aria-live)
  • Button and link purposes clear from label alone
  • Loading states announced (aria-busy, aria-live)

Color and Contrast

  • Text contrast: 4.5:1 normal text, 3:1 large text (โ‰ฅ18pt or 14pt bold)
  • UI components: 3:1 against adjacent colors
  • Information not conveyed by color alone (also shape/text/pattern)
  • Check in Windows High Contrast mode

Content and Structure

  • One h1 per page
  • Skip navigation link at top (visible on focus)
  • Page has
    ,
  • Tables have and appropriate scope attributes

---

Color Contrast Utility

// src/lib/contrast.ts โ€” Check WCAG contrast ratio programmatically
export function hexToRgb(hex: string): [number, number, number] {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  if (!result) throw new Error(`Invalid hex color: ${hex}`);
  return [
    parseInt(result[1], 16),
    parseInt(result[2], 16),
    parseInt(result[3], 16),
  ];
}

function relativeLuminance(r: number, g: number, b: number): number {
  const [rs, gs, bs] = [r, g, b].map((c) => {
    const sRGB = c / 255;
    return sRGB <= 0.03928 ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

export function contrastRatio(foreground: string, background: string): number {
  const [r1, g1, b1] = hexToRgb(foreground);
  const [r2, g2, b2] = hexToRgb(background);
  const L1 = relativeLuminance(r1, g1, b1);
  const L2 = relativeLuminance(r2, g2, b2);
  const lighter = Math.max(L1, L2);
  const darker  = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

export function meetsWCAG(
  foreground: string,
  background: string,
  level: 'AA' | 'AAA' = 'AA',
  size: 'normal' | 'large' = 'normal',
): boolean {
  const ratio = contrastRatio(foreground, background);
  const threshold = level === 'AA'
    ? (size === 'large' ? 3 : 4.5)
    : (size === 'large' ? 4.5 : 7);
  return ratio >= threshold;
}

// Usage:
// meetsWCAG('#2563EB', '#FFFFFF') โ†’ true (ratio ~5.9:1)
// meetsWCAG('#9CA3AF', '#FFFFFF') โ†’ false (ratio ~2.6:1 โ€” too low)

Our Capabilities

We audit and implement accessibility for web applications โ€” from automated axe-core testing pipelines through manual screen reader testing and WCAG 2.2 remediation.

What we deliver:

  • axe-core integration in Jest unit tests and Playwright E2E
  • GitHub Actions accessibility CI gate
  • Accessible React component patterns (forms, modals, navigation)
  • WCAG 2.2 AA audit report with prioritized remediation plan
  • Manual testing with NVDA/VoiceOver and keyboard navigation

โ†’ Request an accessibility audit โ†’ Web development services


Additional Resources

How axe-core Playwright Accessibility Testing Works in 2026

Running axe-core Playwright accessibility testing in 2026 means injecting the axe-core engine into a live browser page and asserting against WCAG 2.2 success criteria as part of your end-to-end suite. The official integration wraps axe-core in a builder that you point at the rendered DOM, then returns structured violations with rule IDs, impact levels, and the exact failing nodes. Because Playwright drives real Chromium, Firefox, and WebKit, you catch contrast, ARIA, and keyboard-focus issues in the same run as your functional checks.

At Viprasol, our senior engineers wire automated accessibility checks straight into CI so regressions fail the build, not the audit. We scope rules per page, suppress known third-party noise, and pair the automated pass with manual screen-reader verification. You get full ownership of a maintainable suite, not a one-off report.

accessibilitytestingwcagplaywrightreact
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

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.