Only 4.1% of websites meet WCAG 2.2 Level AA standards. The WebAIM Million 2025 report found 95.9% of websites fail basic accessibility requirements, averaging 51 errors per page. With 4,605 ADA lawsuits filed against websites in 2024 and the European Accessibility Act enforceable from June 2025, accessibility is now a legal and business risk, not just a best practice. Axe-core, Deque's open-source accessibility engine (downloaded 4 billion times), can automatically detect 57% of WCAG violations — the other 43% require manual review. I've integrated axe-core into the CI pipeline for matthewswong.com and it catches real issues before every deploy.
Automated accessibility tools excel at structural issues: missing alt text on images, form inputs without labels, insufficient color contrast, missing ARIA roles, heading hierarchy violations, keyboard focus order problems, and missing landmark regions. These are the mechanical rules of WCAG. What they can't catch: whether alt text is meaningful (they can check it exists, not whether it describes the image accurately), whether a custom widget is actually keyboard-navigable by a screen reader user, or whether the reading order makes semantic sense. Automated testing is the starting point, not the complete solution.
Axe-core runs accessibility checks in a browser context and returns structured results: violations (definite issues), incomplete (checks that require manual review), passes, and inapplicable (rules that don't apply to the current page). Each violation includes the HTML element causing the issue, the WCAG success criterion violated, an impact rating (critical/serious/moderate/minor), and remediation guidance. The @axe-core/playwright package integrates axe with Playwright tests — check entire pages or individual components.
The simplest manual accessibility test: unplug your mouse and try to use your own application with only the keyboard. Tab to navigate between interactive elements. Enter/Space to activate buttons. Arrow keys for menus and sliders. Escape to close modals. If you can't reach a feature with keyboard alone, keyboard-only users (and screen reader users, who rely on keyboard navigation) can't access it either. In my Next.js projects, I run this test on every new interactive component before shipping. Playwright can automate keyboard navigation testing with `page.keyboard.press('Tab')` sequences.
Accessibility Testing Coverage:
────────────────────────────────────────────────────────────
Automated (axe-core catches ~57% of WCAG violations):
✓ Missing alt text on images
✓ Form inputs without labels
✓ Color contrast failures (4.5:1 for text, 3:1 for large)
✓ Missing ARIA landmarks (main, nav, header, footer)
✓ Heading hierarchy violations (h1 → h3, skipping h2)
✓ Empty buttons/links (no accessible name)
✓ Missing lang attribute on <html>
Manual review required (43% of violations):
✗ Alt text accuracy (exists but meaningless "image.jpg")
✗ Keyboard navigation logic and flow order
✗ Screen reader announcement quality
✗ Context-dependent ARIA usage
✗ Cognitive accessibility (plain language, readability)
WCAG 2.2 Compliance Stats (WebAIM Million 2025):
Websites passing Level AA: 4.1%
Average errors per page: 51 (down from 56.8 in 2024)
Most common failures:
1. Low contrast text (83% of sites)
2. Missing alt text (55% of sites)
3. Empty links (50% of sites)
4. Missing form labels (48% of sites)From my experience auditing matthewswong.com for accessibility: the most common violations I found were 1) icon buttons without accessible names (an X button to close a modal with no aria-label or sr-only text), 2) color contrast failures on text over gradient backgrounds, and 3) missing focus indicators on custom-styled interactive elements. All three are easy to fix once you know they exist. Add aria-label to icon buttons, check contrast with a tool like WebAIM's contrast checker, and ensure your CSS focus styles are visible (never just `outline: none` without a replacement).
The @axe-core/playwright package adds a `checkA11y()` method that runs axe in the current browser context and fails the test if violations are found. Run it after navigating to each page in your E2E test suite. This catches accessibility regressions introduced by any code change — a new component, a CSS change that reduces contrast, or a missing label on a new form field. Configure it to only fail on critical and serious violations initially, then tighten to moderate as you resolve issues.
Tailwind's default color palette is accessible — Tailwind 3+ includes contrast ratio information in the docs for each color combination. The risk is custom colors added to tailwind.config.ts: a brand color that looks fine visually but fails the 4.5:1 contrast ratio for normal text (WCAG AA). Check all custom colors with WebAIM's contrast checker or the axe DevTools browser extension. For text on colored backgrounds, follow the rule: normal text needs 4.5:1, large text (18px+ or 14px bold) needs 3:1. When in doubt, use Tailwind's official palette rather than custom colors.
// npm install @axe-core/playwright
// accessibility.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
const criticalPages = ['/', '/blog', '/about', '/contact']
for (const path of criticalPages) {
test(`${path} has no critical accessibility violations`, async ({ page }) => {
await page.goto(path)
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze()
// Fail on critical violations, warn on moderate
const criticalViolations = results.violations.filter(
v => v.impact === 'critical' || v.impact === 'serious'
)
if (criticalViolations.length > 0) {
console.log('Critical violations:', JSON.stringify(criticalViolations, null, 2))
}
expect(criticalViolations, `Critical a11y violations on ${path}`).toHaveLength(0)
})
}
// Component-level accessibility test
test('icon buttons have accessible names', async ({ page }) => {
await page.goto('/dashboard')
// Find all buttons and verify they have accessible names
const buttons = page.getByRole('button')
const buttonCount = await buttons.count()
for (let i = 0; i < buttonCount; i++) {
const button = buttons.nth(i)
const name = await button.getAttribute('aria-label') ||
await button.textContent()
expect(name?.trim(), `Button #${i} has no accessible name`).toBeTruthy()
}
})
// Keyboard navigation test
test('modal is keyboard accessible', async ({ page }) => {
await page.goto('/invoices')
await page.keyboard.press('Tab') // focus first interactive element
await page.getByRole('button', { name: 'New Invoice' }).focus()
await page.keyboard.press('Enter') // open modal
await expect(page.getByRole('dialog')).toBeVisible()
await page.keyboard.press('Escape') // close modal
await expect(page.getByRole('dialog')).not.toBeVisible()
})Automated tools and keyboard testing cover a lot, but screen reader testing reveals how your content flows when visual layout is stripped away. Test with NVDA (free, Windows), JAWS (the most common enterprise screen reader, Windows), or VoiceOver (built into macOS and iOS). The key things to verify: headings create a logical outline (use the screen reader's heading navigation to see your h1-h6 structure), links have descriptive text ('click here' is useless to screen reader users), form fields announce their labels when focused, and error messages are announced after form submission.
Adding ARIA attributes to fix accessibility issues without understanding them is dangerous — incorrect ARIA can make a page less accessible than no ARIA at all. The ARIA authoring practices rule: 'No ARIA is better than bad ARIA.' Common mistakes: adding role='button' to a div instead of using a real button element (you then must implement keyboard focus, Enter key, and Space key handling manually), using aria-hidden on visible content, and nesting interactive elements inside elements with aria-label. Before adding ARIA to fix an audit finding, read the ARIA Authoring Practices Guide for the pattern you're implementing.
The highest-leverage approach: fix accessibility at the component level so every page that uses the component is accessible by default. If your Button component always has a visible focus state and your Input always has a paired label, pages are accessible by default without per-page audit. I use Radix UI primitives in my Next.js projects — they're headless, unstyled, and fully keyboard-accessible and screen-reader-accessible by specification. Styling Radix components with Tailwind gives you accessible components without writing ARIA logic from scratch.
My accessibility CI workflow: Playwright tests run axe-core on all critical pages (home, login, dashboard, key user flows) on every PR. Violations are reported as GitHub Actions annotations with the element HTML and remediation guidance. Critical violations (missing labels, keyboard traps) fail the build. Serious violations post a warning but don't block merge — giving the team a week to fix before the threshold tightens. Monthly, I run a full axe-cli scan across all pages and review the incomplete list for manual remediation. The incremental approach has moved matthewswong.com from 12 violations to 0 critical violations in three months.