Dev notes
Adding WCAG 2.1 AA compliance to the app. Not a weekend checkbox exercise — a systematic audit of every primitive component, backed by automated axe scans at both the unit and E2E layers. The interesting part is where the tooling helps and where it doesn't.
Lighthouse gives you a score. Scores are comforting and mostly useless. A page can score 100 and still be unusable with a keyboard because Lighthouse doesn't test interaction patterns — it checks static HTML snapshots. The real gaps are in focus management, keyboard navigation, and dynamic content announcements. You need tests that render your components and poke at them.
The app already had @axe-core/playwright running in E2E tests against public routes. That catches page-level issues — missing landmarks, broken heading hierarchy, color contrast on the rendered page. But E2E tests are slow and coarse. You can't easily test every variant of a button or every error state of a form.
The new layer is vitest-axe — axe-core running inside unit tests with Testing Library. Render a component, pass the container to axe, assert zero violations. It runs in milliseconds, catches the same WCAG rules, and you can test every prop combination. The two layers complement each other: unit tests catch component-level violations early, E2E tests catch composition issues where individually accessible components break when assembled.
// src/test/a11y.ts
const axe = configureAxe({
runOnly: {
type: "tag",
values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
},
});
// in any component test
const { container } = render(<Button>Save</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();The approach is to start at the bottom of the component tree and work up. The app has eight primitive components in src/components/ui/: Button, IconButton, Input, Textarea, Modal, Tooltip, InfoTip, and Chip. Every feature page is built from these, so fixing them fixes a large surface area.
Each component gets its own audit pass. The pattern is the same every time: write a failing axe test, fix the violation, then add behavioral tests for keyboard interaction and screen reader announcements. Some highlights:
aria-label. Making it a required prop means TypeScript catches the missing label at compile time, not in a browser audit.htmlFor/ id pair, screen readers announce the input with no context. Error messages need aria-describedby and aria-invalid so they're announced when the field gets focus.onFocus/onBlur and dismissing on Escape (WCAG 1.4.13).aria-modal="true" and wasn't marking background content as inert.Axe is great at structural checks — missing alt text, broken ARIA references, contrast ratios, heading hierarchy. It can't test interaction patterns. It won't tell you that your focus trap has a hole, or that your Tooltip doesn't show on keyboard focus, or that your Chip removal doesn't announce anything to screen readers. Those are behavioral tests you have to write yourself with Testing Library and user-event.
The 3D pages (particle lab, motion lab, the v1 landing hero) are inherently inaccessible as visual experiences. The approach there is descriptive ARIA labels on the canvas container and making sure the page is usable without the 3D content. You can't make a WebGL scene screen-reader-friendly, but you can make sure it doesn't trap focus or block navigation.
Unit-level axe tests run in the existing npm test step — no CI changes needed. The E2E axe scans already ran against public routes. The gap was authenticated routes: calendar, vitals, settings, and the operator dashboard. Those need real Auth0 credentials that CI doesn't have for forks, so they run as a separate local-only test script until the secrets are configured in the repo.
Every page needs a skip-to-content link (visually hidden, visible on focus) and semantic landmarks: <header> for the nav bar, <main> for the content, <nav> for navigation. Screen reader users navigate by landmarks — if your page is all <div>s, they're scrolling through the entire DOM linearly.
The app uses Framer Motion for page transitions, card reveals, and scroll animations. Users with vestibular disorders can set prefers-reduced-motion in their OS. The v2 landing sections already respected this via useReducedMotion(). The audit extends it to everywhere Framer Motion is used: essential animations collapse to simple opacity fades, decorative ones (3D scenes, particle effects) disable entirely.
WCAG SC 1.4.3 requires a 4.5:1 contrast ratio for normal text and 3:1 for large text (18px+ or 14px+ bold). SC 1.4.11 extends the 3:1 minimum to UI components and graphical objects. Axe catches most contrast issues at render time, but dynamic states (hover, focus, active) need manual verification.
The audit found several muted text colors sitting below the 4.5:1 threshold against both light and dark backgrounds. Adjusting these was straightforward, the tricky part was making sure the fixes held across both themes. The design token system helps here since contrast only needs to be verified at the token level, not per-component.
Every new component should follow the same three-layer test pattern that came out of the audit. First, axe scans for every visual variant (default, loading, error, disabled, empty). Each variant can produce different DOM structures that need separate evaluation. Second, label and ARIA assertions using getByLabelText, aria-describedby checks, and role verification. Third, keyboard behavior tests with user-event — tab order, Escape dismissal, Enter/Space activation.
The specifics to verify depend on what the component does:
htmlFor/id or aria-label. Use useId() for stable IDs.aria-disabled over native disabled so elements stay keyboard-focusable.tabIndex values greater than 0.prefers-reduced-motion. Essential animations become opacity fades, decorative ones disable.aria-live="polite", error messages use role="alert".WCAG (Web Content Accessibility Guidelines) sounds like a dense legal document, and honestly, parts of it are. But the core idea is simple: people interact with the web in different ways, and your UI shouldn't assume they're all using a mouse with perfect vision. WCAG organizes everything under four principles, remembered by the acronym POUR:
There are three conformance levels: A (bare minimum), AA (the standard everyone targets and what most legal requirements reference), and AAA (ideal but rarely required in full). This project targets AA across the board.
Each rule is called a "Success Criterion" and has a number like SC 1.4.3 (contrast) or SC 2.1.1 (keyboard). You don't need to memorize them, but knowing the numbering system helps when you see them referenced in axe violations or audit reports. The first digit maps to POUR: 1 = Perceivable, 2 = Operable, 3 = Understandable, 4 = Robust.
The fastest way to build real intuition is to use your own app without a mouse. Tab through the page. Can you reach every button? Can you tell where focus is? Can you dismiss a modal with Escape? Try a screen reader too — VoiceOver is built into macOS (Cmd+F5). You'll immediately feel the difference between a labeled input and an unlabeled one.
After that, the most practical learning path:
<div> and <span> for everything. Use <button>, <nav>, <main>, <label> — the browser gives you keyboard support, screen reader announcements, and focus management for free. ARIA is a patch for when HTML doesn't have the right element, not a replacement for using the right element.You don't need to read the full WCAG spec. Most day-to-day web development touches the same dozen or so criteria repeatedly: labels (1.3.1, 4.1.2), keyboard access (2.1.1), focus visible (2.4.7), contrast (1.4.3), error identification (3.3.1), and name/role/value (4.1.2). Get comfortable with those and you're ahead of most developers.
A quick reference for reviewing PRs that touch UI. Not every item applies to every PR, but scanning the list catches the common gaps.
aria-describedby and announced with role="alert"aria-labelprefers-reduced-motiontabIndex values greater than 0<main>, <nav>, <header>)The project now has three layers catching accessibility issues at different stages. First, eslint-plugin-jsx-a11y runs recommended WCAG rules at lint time, catching structural issues like missing alt text, invalid ARIA attributes, and non-interactive elements with click handlers before the code even runs. Second, vitest-axe runs axe-core inside unit tests, scanning every component variant against WCAG 2.1 AA criteria in milliseconds. Third, @axe-core/playwright runs full-page axe scans in E2E tests against rendered routes.
Each layer catches things the others miss: lint catches patterns (like a div with onClick but no role), unit tests catch rendered DOM issues (like a generated ID that doesn't match), and E2E tests catch composition problems (like two components that individually pass but together create duplicate landmarks).
The bottom-up approach (primitives first) left a gap: feature-level components in operator/, calendar/, fantasy/, and learn/ were assembled from accessible primitives but introduced their own violations. Operator pages had charts with no text alternatives (screen readers announced empty containers), skeleton loaders that cluttered the accessibility tree, and stock bars with no semantic meaning. The calendar had a combobox that couldn't be operated by keyboard, toggle buttons with no ARIA state, and time grid slots that were click-only.
The fix was systematic: run axe on every feature component, fix what it finds, then add behavioral tests for keyboard interaction. 29 new tests across two test files cover the feature layer now.
Adding eslint-plugin-jsx-a11y to the ESLint config catches a category of issues that neither axe nor manual testing reliably finds: patterns that are always wrong regardless of runtime state. Things like a div with an onClick handler but no role attribute, an img without alt, an anchor without href, or an interactive element without a keyboard handler. The plugin runs the recommended WCAG rules from the jsx-a11y package.
One gotcha when using it alongside eslint-config-next: Next.js already registers the jsx-a11y plugin internally, so you only add the recommended rules object, not the full plugin config. Trying to register the plugin twice causes a "Cannot redefine plugin" error.
In the browser, the Firefox Accessibility Inspector and the axe DevTools extension complement this by letting you inspect the live accessibility tree, run on-demand axe scans, and simulate how screen readers interpret your page. Between lint, unit tests, and browser dev tools, most violations get caught before they reach a PR.
CalendarGrid has a pattern that doesn't fit neatly into accessibility rules: a day cell that's clickable (to create an event) but also contains clickable EventChip buttons. You can't put role="button" on the outer div because that creates nested interactive elements, which is an axe violation. You can't remove the click handler because clicking empty space in the cell should open the event creator.
The solution is event delegation: the outer div handles onClick but checks if the click target is inside a button or anchor (via closest("button, a")). If it is, the click belongs to the child and the parent ignores it. The div keeps its aria-label for screen readers and handles Enter/Space for keyboard users, but the eslint rule for no-static-element-interactions gets a targeted disable comment since the pattern is intentional. This is the right tradeoff — the alternative (wrapping all empty space in invisible buttons) creates worse keyboard navigation.
Automated tools catch roughly 30-40% of accessibility issues. The rest requires human judgment. Some things to build habits around: Test with sound off to make sure nothing depends solely on audio cues. Test at 200% zoom to verify layouts don't break. Respect user preferences via media queries: prefers-reduced-motion for animations, prefers-color-scheme for themes, prefers-contrast for high-contrast modes.
Make click targets large enough for users with motor impairments and avoid time-limited interactions like auto-dismissing popups. Use skip links so keyboard users can bypass repetitive navigation. Provide captions for video and audio content. Consider offering dyslexia-friendly font options for text-heavy pages. The goal isn't perfection, it's building habits that make accessibility a natural part of development rather than an afterthought audit.
Accessibility tests run at three different points in the development cycle, and each one activates automatically — no extra commands or config needed.
Lint time (eslint-plugin-jsx-a11y) — fires on every save if your editor has ESLint integration, and runs in CI as part of the standard lint step. Catches structural issues before the code even executes: missing alt text, click handlers without keyboard support, invalid ARIA attributes. Zero developer effort to activate — if your ESLint config inherits from the project's eslint.config.mjs, you get the rules.
Unit tests (vitest-axe) — run as part of the normal npm test command alongside all other Vitest tests. The axe matchers are globally registered in src/test/setup.ts, so any test file can import axe from @/test/a11y and call expect(results).toHaveNoViolations(). These run in CI on every push.
E2E tests (@axe-core/playwright) — run via npm run test:e2e for public routes and npm run test:e2e:auth for authenticated routes. Public route scans run in CI. Authenticated scans require real Auth0 credentials and run locally.
The process is the same every time. Write your component, then add an accessibility test alongside your other tests:
axe from @/test/a11y, render the component, pass the container, assert zero violations.getByRole, getByLabelText, and toHaveAttribute to verify roles, labels, aria-expanded, aria-selected, and other ARIA state. These document the component's accessible API.userEvent.keyboard to simulate keypresses and assert the right callbacks fire.<h1>. If it's visually redundant with PageHeader, use sr-only to hide it visually while keeping it in the accessibility tree.text-muted/30). The base token already meets contrast requirements; reducing its opacity drops it below the 4.5:1 threshold.// typical a11y test for a new component
import { axe } from "@/test/a11y";
it("has no axe violations", async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("is keyboard accessible", async () => {
render(<MyComponent onAction={onAction} />);
const trigger = screen.getByRole("button", { name: "Do thing" });
trigger.focus();
await userEvent.keyboard("{Enter}");
expect(onAction).toHaveBeenCalled();
});That's it. The lint rules catch the obvious structural issues as you type, the axe scans catch WCAG violations when tests run, and the E2E layer catches composition issues on the full page. If all three pass, the component is in good shape. The tests run automatically in CI — no manual steps needed beyond writing them.
The aria-live attribute controls how screen readers announce dynamic content updates. aria-live="off" is the default, no announcements. aria-live="polite" waits until the screen reader is idle to announce updates, which is the right default for status messages, search result counts, and saved confirmations. aria-live="assertive" interrupts immediately and should be reserved for critical alerts and errors.
The project uses polite regions for toast notifications (RefreshBar), search result counts (CardSearch), and character counts (Textarea). The key insight is that the live region container must exist in the DOM before the content changes — if you conditionally render the container and the content at the same time, screen readers miss the announcement because they weren't watching the region when it appeared.
The recommended ruleset from eslint-plugin-jsx-a11y is a good starting point, but two rules needed project-level tuning after the first CI run caught 14 issues.
no-noninteractive-tabindex — flags tabIndex={0} on non-interactive elements, which is usually correct. But scrollable containers are the exception: keyboard users need to tab into them to scroll with arrow keys. The fix is to give the container role="region" with an aria-label, then configure the rule to allow tabIndex on region and tabpanel roles. This way the rule still catches genuinely wrong uses while allowing the legitimate accessibility pattern.
// eslint.config.mjs
"jsx-a11y/no-noninteractive-tabindex": [
"error",
{ tags: [], roles: ["tabpanel", "region"] },
],
// in the component
<div
className="overflow-x-auto"
role="region"
aria-label="Stats leaderboard"
tabIndex={0}
>no-unused-vars — the default @typescript-eslint/no-unused-vars config from eslint-config-next doesn't recognize the standard _-prefix convention for intentionally unused variables, and it flags the throwaway variable in rest destructuring patterns like const { name: _, ...rest } = obj. Adding argsIgnorePattern, varsIgnorePattern, and ignoreRestSiblings fixes seven warnings in one config change. Not an accessibility rule, but it came out of the same lint audit.