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.
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
| Level | Criteria | Requirement |
|---|---|---|
| A (minimum) | 30 criteria | Must meet for basic accessibility |
| AA (standard) | +20 criteria | Target for most applications |
| AAA (enhanced) | +28 criteria | Aspirational; not required for most |
Most commonly failed WCAG 2.2 criteria (AA):
- 1.1.1 โ Non-text content: images missing alt text
- 1.3.1 โ Info and relationships: form labels not associated with inputs
- 1.4.3 โ Contrast (minimum): 4.5:1 for normal text, 3:1 for large text
- 2.1.1 โ Keyboard: all functionality operable via keyboard
- 2.4.7 โ Focus visible: keyboard focus indicator visible
- 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:

๐ 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
- Web Accessibility for React Apps
- React Native Testing
- Testing Strategy for Engineering Teams
- Next.js App Router Patterns
- Developer Experience (DX) Metrics
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.
External Resources
About the Author
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.
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
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.