Implement accessibility at scale in a large React app
Build a11y into the design system, not into individual screens. Use semantic HTML + accessible primitives (Radix, React Aria), enforce with linting (eslint-plugin-jsx-a11y) and automated audits (axe in CI + Lighthouse), test with keyboard + a real screen reader (NVDA/VoiceOver), and own metrics like keyboard coverage and contrast pass rate. Treat a11y like a feature with a budget and a tracking dashboard, not a pre-launch checklist.
Accessibility at scale is an architecture problem, not a per-component problem. You can't tag a thousand engineers to remember ARIA — you set up the system so the easy path is the accessible path.
The four layers of an a11y program.
1. Primitives layer — make the right thing easy.
Build (or adopt) an accessible component library: Button, Dialog, Menu, Combobox, Tabs, Tooltip. Each one bakes in:
- Correct semantic element (
<button>, not<div role="button">). - Focus management (trap focus in modal, return focus on close).
- Keyboard support per WAI-ARIA Authoring Practices.
- ARIA states (
aria-expanded,aria-selected,aria-controls). - Live regions for dynamic content (
aria-live="polite").
Don't build these from scratch. Use:
- Radix UI — headless primitives, complete ARIA + keyboard impl.
- React Aria (Adobe) — hooks that give you accessibility without prescribing markup.
- Headless UI — Tailwind-flavored.
Adopt one; ban handrolled modals/menus/dropdowns in code review.
2. Enforcement layer — catch regressions automatically.
eslint-plugin-jsx-a11yflags missingalt, click handlers on non-interactive elements, etc. Make it error-level.- axe-core integrated via
@axe-core/reactin dev mode, andjest-axein component tests:
``tsx expect(await axe(container)).toHaveNoViolations(); ``
- Playwright + @axe-core/playwright for E2E a11y on key flows.
- Storybook
a11yaddon — runs axe per story, surfaces violations to designers. - CI gate — block merge on new a11y errors. Allow-list existing violations with a tracked debt issue, not silent suppression.
3. Testing layer — humans, not bots.
Automation catches ~30% of issues. The rest needs:
- Keyboard-only walk-through of each major flow. Every interaction reachable, focus visible, no traps.
- Screen reader (NVDA on Windows, VoiceOver on Mac/iOS, TalkBack on Android) — sample a handful of pages per release. Look for announcement order, missing labels, redundant info.
- Zoom + reduced-motion — text scales to 200%, animations respect
prefers-reduced-motion. - Contrast — design system enforces tokens; design reviews check derived states (hover, disabled).
4. Culture & metrics layer.
- Dashboard: % of pages passing axe with zero violations, contrast pass rate by token, % of components keyboard-tested. Make it visible.
- Per-team budgets: a violation per page max, owned by the team shipping the page.
- Accessibility champions — one per pod, reviewer-of-record for a11y PRs.
- Training: 1-hour onboarding (keyboard + screen reader basics), recurring office hours.
Concrete patterns you'll repeat.
- Skip link —
<a href="#main" class="sr-only-focusable">Skip to content</a>. - Focus management on route change — move focus to
<h1>of new page so SR users hear it. - Modals — trap focus inside, return focus on close, ESC closes, click-outside closes (but not for destructive actions).
- Forms — every
<input>has a programmatic label (visible<label>oraria-labelledby); errors associated viaaria-describedby; field-level + summary error patterns. - Live regions —
role="status"for toasts,role="alert"for errors,aria-live="polite"for non-urgent updates. - Icon buttons —
aria-labelor visually-hidden label; icon-only buttons without one are invisible to SR users. - Dynamic content — when content updates without a route change (search results, data tables), announce via live region.
The bait questions interviewers love.
- "Why prefer
<button>over<div onClick>?" — Native button is focusable, has Space/Enter activation, has role, hasdisabledsemantics, is form-submittable. Adivhas none. - "How do you make a custom dropdown accessible?" — Radix/React Aria, or implement WAI-ARIA combobox spec (10+ keyboard interactions). Don't pretend to remember all of them.
- "What's the difference between
aria-hiddenandrole=presentation?" —aria-hiddenremoves the subtree from the accessibility tree entirely (don't use on focusable elements).role=presentationstrips the semantic role of the element but children remain. - "When do you need
aria-live?" — for content that changes without user navigation and that matters to know about: form errors, status messages, async results.
Senior framing. The interviewer is checking whether the candidate has shipped a11y at scale, or just learned a checklist. The proof points: design system ownership, automated gates in CI, recurring screen-reader testing, and a dashboard. The candidate who says "I always add aria-label to my buttons" is junior. The candidate who says "We made our Button primitive enforce a label at the type level, and our axe CI gate has been zero-violation for 18 months" is senior.
Follow-up questions
- •Why prefer Radix / React Aria over hand-rolled primitives?
- •What does axe catch and what does it miss?
- •How do you announce dynamic content to screen readers?
- •Difference between aria-hidden, role=presentation, and visually-hidden CSS?
Common mistakes
- •Using `<div onClick>` instead of `<button>`.
- •Trapping focus in a modal but never returning focus on close.
- •Toasts that don't use a live region — invisible to screen readers.
- •Relying solely on automated tests; never touching a real screen reader.
Performance considerations
- •axe is fast in dev, but full-page audits in CI can add minutes — scope to changed routes.
- •Live regions don't cause re-renders, but excessive announcements (every key press) overwhelm SR users.
Edge cases
- •Single-page-app route change — must move focus + announce, otherwise SR users get no signal.
- •Forms with conditional fields — labels and error wiring must stay consistent as the DOM changes.
- •Drag-and-drop — provide a keyboard alternative (move up/down with arrows).
Real-world examples
- •GitHub, Atlassian, and Adobe ship dedicated a11y teams + dashboards.
- •Reach UI / Radix originated as solutions for this scaling problem.