Accessibility Engineering at Scale
You run Lighthouse on your application and it returns a 94 accessibility score. The team marks accessibility as done. Ship it.
Six months later, a screen reader user files a support ticket. The address autocomplete dropdown is invisible to them. The quantity stepper has no name their software can announce. Pressing Escape in the payment modal dumps focus back to the top of the page instead of the button that opened it. Three failures in one checkout flow. Lighthouse saw none of them. It scans a frozen snapshot of the page. Your users navigate a live one. They’re stuck. Focus disappears. The keyboard does nothing.
A Lighthouse score is a building inspection that only checks the lobby. The hallways, the elevators, the emergency exits? Nobody walked those.
- A passing automated scan does not mean your site is accessible. Keyboard navigation, screen reader flow, and focus management require manual testing that no scanner can replace.
- ARIA overuse is more harmful than underuse. Audit any production codebase and most ARIA you find is doing harm, not good.
- Retrofitting accessibility costs far more than building it in. The DOM structure, ARIA patterns, and keyboard handling all need rethinking after the fact.
- Two screen reader combos are all you need. NVDA + Chrome on Windows and VoiceOver + Safari on macOS. Every interactive component needs both.
- CI gates are the only defense against regression. A single quarter of unguarded PRs can undo six months of accessibility work.
| What automated scanners catch | What only manual testing catches |
|---|---|
| Missing alt text, empty links | Whether alt text is meaningful in context |
| Contrast ratio violations | Readability under real-world screen conditions |
| Missing form labels, duplicate IDs | Logical focus order through multi-step flows |
| Invalid ARIA attribute values | Whether ARIA announcements make sense in sequence |
| Missing landmark regions | Keyboard use of custom widgets |
| Color-only state indicators | Screen reader behavior differences (NVDA vs VoiceOver) |
The ARIA Paradox: More Is Worse
ARIA fills gaps where HTML falls short. A custom dropdown built from div elements? It needs role="listbox", aria-expanded, and aria-activedescendant so the screen reader knows what it’s looking at. Get the ARIA right and custom widgets become usable. Get it wrong and the screen reader confidently announces something untrue. GPS telling you to turn left into a lake. Worse than no directions at all.
The first rule, straight from the W3C ARIA spec : don’t use ARIA if a native HTML element already does the job.
Audit your component library for ARIA density. Components with more than 3-4 ARIA attributes per element are over-decorated. ARIA is seasoning, not the main course. Most production codebases use it like they’re salting for deer.
Common ARIA anti-patterns:
| Don’t | Do | Why |
|---|---|---|
<div role="button"> | <button> | Native element gets keyboard, focus, and announcements for free |
<a href="/page" role="button"> | <a href="/page"> or <button onclick> | Links navigate. Buttons act. Mixing them lies to the screen reader. |
<input aria-label="Name"> with visible label | <label for="name">Name</label> | Two names fighting each other. The visible label gets ignored. |
aria-hidden="true" on focusable element | Remove from tab order first | Hidden from screen reader but keyboard still reaches it. An invisible trap door. |
Keyboard Navigation Architecture
Keyboard accessibility isn’t slapping tabindex="0" on everything. It’s a real architecture decision. Give it the same thought you give routing or state management.
Tab order must follow visual reading order. When CSS Grid or Flexbox reorders elements visually, the two fall out of sync. Focus jumps across the layout. Floor 1, floor 7, floor 3. Willy Wonka’s elevator, but nobody’s having fun. The fix isn’t tabindex values. It’s making the DOM match what the eye sees.
For complex controls like tab panels, menu bars, and data grids, the roving tabindex pattern gives the whole widget a single Tab stop. Arrow keys move between items inside it. WAI-ARIA Authoring Practices spell out the expected keyboard behavior for each widget type.
React Router, Vue Router, SvelteKit. None of them handle this out of the box. Three major frameworks, zero focus management. Impressive, in the worst way.
// Route change focus management - React example
useEffect(() => {
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
// Announce new page to screen readers
const announcer = document.getElementById('route-announcer');
if (announcer) announcer.textContent = document.title;
}, [location.pathname]);
The design systems engineering guide covers building keyboard patterns into component libraries from the start. That way teams don’t reinvent focus management in different, broken ways.
Route changes are one kind of focus disruption. Modals are another, and they’re sneakier.
Focus Trapping in Modals and Overlays
The <dialog> element handles focus trapping natively when you open it with showModal(). It creates a top layer. Tab and Shift+Tab cycle inside the modal. Focus returns on close. Just use it.
Stuck with <div>-based modals? Then you’re tracking every focusable element yourself, wrapping Tab at the edges, handling Escape, updating the trap when lazy content adds new fields. A room where the door is supposed to lock behind you, but you built the lock yourself and missed three windows. Every team underestimates this. (Every team.)
The inert attribute helps both approaches. Apply it to the main content wrapper when a modal opens. The browser pulls all background elements out of the tab order and the accessibility tree. The rest of the building goes dark while the modal has your attention. If you’re building new modals without <dialog> + inert, you’re reinventing the wheel. And it’s on fire.
Color Contrast Beyond WCAG AA
WCAG AA requires 4.5:1 contrast for normal text and 3:1 for large text. These are minimums, and building to minimums is like studying just enough to pass. You won’t.
The 4.5:1 threshold assumes perfect conditions. Properly adjusted screen. Good lighting. Normal vision. The real world has screen glare, low brightness, aging displays, and roughly 8% of males with some form of color vision deficiency . All of that chips away at the contrast your users actually see. Build to AAA ratios (7:1 for normal text, 4.5:1 for large text) and you get breathing room that survives actual conditions.
Contrast isn’t just text. Focus indicators, selected states, disabled states all need contrast too. WCAG 2.1 added a 3:1 non-text contrast requirement for UI components. Most teams only check body text and call it done. That’s checking the front door paint while ignoring the fire escape signs.
| Element Type | WCAG AA Minimum | WCAG AAA Target | Real-World Note |
|---|---|---|---|
| Normal text (<18px) | 4.5:1 | 7:1 | 4.5:1 fails under screen glare, low brightness, or display aging. Target 7:1 for body text |
| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 | Larger glyphs compensate for lower contrast. Still test on mobile screens |
| UI components (icons, borders, focus rings) | 3:1 | N/A | Non-text elements that convey meaning. Often overlooked in audits |
| Decorative text | No requirement | N/A | Purely decorative elements are exempt. But if it carries meaning, it needs contrast |
Never rely on color alone to show state. A red error border and a green success border look the same to someone with red-green color blindness. Your UI is playing charades with 8% of the male population. Pair color with icons, text labels, or pattern changes. Every time.
None of this survives if the next sprint’s PRs quietly break what you’ve built.
CI Pipeline Accessibility Gates
A team spends a quarter building accessible components. The next quarter, twelve PRs land without accessibility review. A refactored modal loses its focus trap. A CSS change breaks focus indicator contrast. None trigger a test failure. Six months of work, quietly undone. The inspectors visited during construction and never came back. Tenants knocked out load-bearing walls. Entropy always wins unless you automate the fight.
CI gates are the only reliable defense.
- axe-core integrated into component test runner (jest-axe for React, equivalent for Vue/Angular)
- Playwright or Cypress configured with
@axe-core/playwrightfor full-page integration scans - Storybook accessibility addon running against every story variation, including disabled and loading states
- PR template includes accessibility acceptance criteria as checkable items
- At least two team members can test with NVDA and VoiceOver for manual review
The baseline: axe-core
in your test runner, catching the 30-40% of issues that scanners can find. jest-axe for component unit tests, Playwright’s @axe-core/playwright for full-page tests. Both on every PR. No exceptions.
The step most teams skip? Storybook accessibility testing. The @storybook/addon-a11y runs axe-core against every story variation. A button might pass in its default state but fail when disabled (not enough contrast) or loading (missing aria-busy). Skipping this means you’re testing the sunny day while shipping the thunderstorm.
Beyond scanning, accessibility acceptance criteria belong in the definition of done. “Focus moves to modal content on open and returns to trigger on close” is a testable requirement. Put it in the PR template. The PR doesn’t merge until it’s verified. No checkbox, no merge. Simple as that.
Same model as security scanning in DevOps : every PR, blocks merge, clear ownership.
Screen Reader Testing That Catches Real Issues
Scanners catch structure. Screen readers catch experience. Big difference.
Two test combos cover the most common screen reader/browser pairings: NVDA + Chrome on Windows, VoiceOver + Safari on macOS. Each reads ARIA differently. Timing on live region announcements, detail level on dynamic content. What works in NVDA will surprise you in VoiceOver. Same spec, different accents.
The testing workflow: open the page with the screen reader active. Navigate using only the keyboard. Does it announce the role, name, and state? Interact with each control. Does the announcement update? Trigger dynamic content. Does the live region fire?
Write down what each screen reader says at each step. This transcript is your real accessibility spec. If NVDA says “button, Submit Order, expanded” and VoiceOver says “Submit Order, collapsed, button,” you’ve got a state sync bug that no automated tool will ever find. Two witnesses to the same event telling different stories.
For teams scaling accessibility UX engineering across multiple products, a shared library of screen reader transcripts per component type keeps teams from solving the same puzzles twice.
Live Regions and Dynamic Content
SPAs change content without page reloads. Sighted users see the change. Screen reader users hear nothing unless you announce it through a live region. The PA system in your building. If it’s not wired up, nobody outside the room knows what just happened.
aria-live="polite" announces when the screen reader finishes its current task. Use for non-urgent updates. aria-live="assertive" interrupts right away. Save it for errors and urgent alerts. Using “assertive” for a toast notification is like pulling the fire alarm to announce lunch.
The pattern: keep a visually hidden live region in your application root. Don’t add live regions on the fly. Some screen readers ignore regions added after the page loads.
<!-- In your app root - must exist before content changes -->
<div id="a11y-announcer" aria-live="polite" class="sr-only"></div>
// Announce after async operation completes
function announce(message) {
const el = document.getElementById('a11y-announcer');
el.textContent = ''; // Clear first to trigger change
requestAnimationFrame(() => { el.textContent = message; });
}
// Usage
announce('3 search results loaded');
announce('Form saved successfully');
When Full Accessibility Engineering Is Overkill
Not every project needs the full treatment. Static sites with no interactive components can get close with automated scanning alone.
| Full accessibility engineering needed | Automated scanning is enough |
|---|---|
| SPAs with client-side routing | Static content sites (blogs, docs) |
| Custom interactive widgets (comboboxes, data grids) | Pages using only native HTML elements |
| Multi-step forms and checkout flows | Simple contact forms with native inputs |
| Dynamic content updates (live feeds, notifications) | Server-rendered pages with full page reloads |
| Applications targeting regulated industries | Internal tools with small, known user base |
If your site has a single custom dropdown, you’ve crossed the line. Test it with a screen reader. Dropdowns are the Bermuda Triangle of accessibility.
What the Industry Gets Wrong About Accessibility Engineering
“Run Lighthouse and fix the red flags.” Lighthouse catches 30-40% of issues. The 60-70% it misses are the ones that actually block users: broken focus management, nonsensical screen reader announcements, custom widgets that ignore the keyboard. Checking the lobby and calling the building safe.
“ARIA makes everything accessible.” ARIA is a last resort, not a first tool. Native HTML elements give you keyboard handling, focus management, and screen reader announcements for free. Most ARIA in production codebases is pointless or harmful. More signs doesn’t mean better wayfinding. Sometimes it means nobody can read any of them.
“Accessibility is a design problem.” It’s an engineering architecture problem. Keyboard focus flow, live region timing, focus trap implementation, route change management in SPAs. Design sets contrast ratios and color usage. Engineering decides whether a keyboard user can finish a purchase.
Cost comparison: building accessible vs. retrofitting
| Approach | Dev Time | Risk | Long-term Cost |
|---|---|---|---|
| Built accessible from the start | Small overhead per component | Low | Baseline |
| Retrofitted after launch | Rework DOM, ARIA, keyboard handlers | High (breaks things) | Far more expensive |
| “Fix Lighthouse findings only” | Cosmetic patches only | Very high (false sense of safety) | Lawsuit + full retrofit later |
Web application engineering that treats accessibility as architecture avoids the retrofit entirely. The simplest culture shift? Tab through your own component before you ask for review. If focus jumps somewhere unexpected, the component isn’t done. Make it as automatic as running the test suite.
That Lighthouse 94? Three checkout failures hiding behind it, all findable with a single Tab key. Build the elevator shaft into the blueprint, or pay to tear the building apart later.