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