Back to Blog

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

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
August 21, 2026
12 min read

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

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 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

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:

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

Working With Viprasol

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


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.