Hard-earned notes on shipping WCAG 2.1 AA across a creator platform: semantic HTML over ARIA, focus management that survives SPA navigation, and audits that actually catch regressions in CI.
A Tuesday morning at the creator economy platform I spent the last few years at. A creator filed a support ticket that started with “my customers using JAWS can’t actually buy anything.” She’d attached a screen recording. The checkout form looked fine visually. The screen reader read the price label twice, skipped the entire payment fieldset, and announced the submit button as “button button button” before landing on something useful. We had a full WCAG 2.1 AA push coming up. That ticket became exhibit A.
I’m going to talk about what actually moved the needle on accessibility for us, in a React and TypeScript app that millions of people touched. Not the conference-slide version. The version where you ship things, break things, and find out which audits lie to you.
OK so the first lesson, and honestly the only one that matters if you only read one section. Reach for the native element first. Every time. ARIA is a patch layer. Patches drift.
Half our accessibility bugs at the creator platform were <div role="button" tabindex="0" onClick=...> patterns written by someone who didn’t realize a <button> already does focus, keyboard activation, and the right screen-reader announcement for free. The fix wasn’t a sprint. It was a codemod and a lint rule.
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/cn';
type Variant = 'primary' | 'secondary' | 'ghost';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', isLoading, disabled, children, className, ...rest }, ref) => {
return (
<button
ref={ref}
type={rest.type ?? 'button'}
disabled={disabled || isLoading}
aria-busy={isLoading || undefined}
className={cn('btn', `btn-${variant}`, className)}
{...rest}
>
{isLoading ? <span aria-live="polite">Loading...</span> : children}
</button>
);
},
);
Button.displayName = 'Button';
That type="button" default has saved me from at least three accidental form submits inside modals. Default <button> behavior inside a <form> is submit. You will forget this. I have.
The thing the screen reader users kept telling us, in different words, was the same thing. After a route change, focus disappears into the void. You click a link, the page swaps, the URL updates, focus is still sitting on a link element that no longer exists. The screen reader goes quiet. You don’t know if anything happened.
Native browser navigation handles this. Client-side routing does not, unless you write the bit yourself. Here’s the hook I ended up with on the community surface I helped own.
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
export function useFocusOnRouteChange() {
const location = useLocation();
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
const node = headingRef.current;
if (!node) return;
// tabindex -1 so the heading is programmatically focusable but not in the tab order
node.setAttribute('tabindex', '-1');
node.focus({ preventScroll: false });
return () => node.removeAttribute('tabindex');
}, [location.pathname]);
return headingRef;
}
Pair that with a small live region for route announcements, attached once at the app root.
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
export function RouteAnnouncer() {
const location = useLocation();
const [message, setMessage] = useState('');
useEffect(() => {
const title = document.title || 'Page changed';
setMessage(`Navigated to ${title}`);
}, [location.pathname]);
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', border: 0 }}
>
{message}
</div>
);
}
Yeah, that ugly inline style block. Don’t replace it with a Tailwind class called sr-only and call it done. Test it. display: none and visibility: hidden both hide the element from assistive tech too. The clip-path dance is the one that consistently works.
This one I’m not proud of. At a live-video creator platform I led engineering at. We’d just rolled out a design system migration, atomic CSS, the whole thing. A teammate shipped a new “share to social” modal on the creator profile page. CI passed, visual regression passed, it deployed Friday afternoon. By Saturday I had three emails from sighted keyboard users (not screen reader users, just folks who don’t use a mouse) saying tab navigation went into the modal and never came back.
First wrong fix. Someone added aria-modal="true" to the modal wrapper and called it. That tells assistive tech the modal is modal. It does not actually trap focus. Browsers don’t implement focus trapping for you. We deployed the fix. It didn’t fix.
The real fix was two things. First, a proper focus trap inside the modal, which honestly should have been a primitive in our design system from day one. Second, a check before showing the modal to remember the element that triggered it, so we could put focus back there on close.
import { useEffect, useRef } from 'react';
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function useFocusTrap(active: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!active) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const container = containerRef.current;
if (!container) return;
const focusables = container.querySelectorAll<HTMLElement>(FOCUSABLE);
focusables[0]?.focus();
function onKeyDown(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const nodes = container!.querySelectorAll<HTMLElement>(FOCUSABLE);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
container.addEventListener('keydown', onKeyDown);
return () => {
container.removeEventListener('keydown', onKeyDown);
previouslyFocused.current?.focus();
};
}, [active]);
return containerRef;
}
About 2 hours of broken keyboard nav on a Friday afternoon. Several creators publicly screenshotted the dropdown going off-screen with no way back. Lesson in one sentence. A modal without focus management isn’t a modal, it’s a layout bug with attitude.
Lighthouse will tell you a page is 100. JAWS will tell you the page is unusable. Both can be true.
We added VoiceOver runs to our manual QA gate for any PR touching navigation, modal, or form code. The first time I sat down and actually used VoiceOver on our own checkout flow, I learned more in 30 minutes than the previous quarter of axe-core reports had taught me. The skip link skipped to a div with no heading. The cart total updated silently. The error message on a declined card appeared visually but never reached the live region.
You don’t need to be a screen reader power user. You need to be able to turn it on, navigate the happy path, and notice when nothing is announced. That’s it. Document the gestures in your team wiki. Make it part of the on-call rotation.
Automated audits are a floor, not a ceiling. The team ran axe-core in two places. Inside component-level Vitest tests for the high-risk primitives, and as a Playwright job in CI hitting the top routes.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const ROUTES = ['/', '/creators', '/checkout', '/community/feed'];
for (const path of ROUTES) {
test(`accessibility audit on ${path}`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.disableRules(['color-contrast']) // run this separately, it generates a lot of noise on dynamic themes
.analyze();
expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
});
}
The disableRules(['color-contrast']) line is the spicy one. Color contrast is real and matters. But on a multi-tenant platform where creators pick their own brand colors, you’ll catch a thousand violations that aren’t your codebase’s problem. Run that check separately, gate it differently, surface it to product. Don’t let it block your CI on a Tuesday afternoon.
aria-modal is not enough.Thanks for reading. If you’ve got thoughts, send them my way.