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.
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
| 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
Recommended Reading
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
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.