Most accessibility work fails for the same reason: it is treated as a QA pass at the end instead of a set of decisions made while building each component. WCAG 2.1 AA is the baseline most contracts and laws require, and almost all of it reduces to a handful of engineering habits. Here are the ones that actually matter in day-to-day React work, with the code.
1. Use the right element — the cheapest win there is
The single biggest source of accessibility bugs is a div pretending to be a button. A real <button> gives you focusability, keyboard activation (Enter and Space), the correct role, and disabled semantics for free. A div gives you none of it, and no amount of ARIA fully replaces it.
// ❌ a div that looks clickable but isn't keyboard-operable or announced
<div className="btn" onClick={save}>Save</div>
// ✅ native semantics: focusable, Enter/Space work, announced as "button"
<button type="button" onClick={save}>Save</button>Rule of thumb: if it navigates, it's an <a href>. If it performs an action, it's a <button>. Reach for role + tabIndex + key handlers only when no native element fits — and then you own all of that behaviour yourself.
2. Make focus visible and managed (2.4.7, 2.4.3)
Keyboard users navigate by focus. Two failures dominate: killing the focus outline for aesthetics, and losing focus when content changes. Never remove the outline without replacing it — use :focus-visible so mouse users don't see a ring but keyboard users do.
/* ❌ never do this alone — it strips the only cue keyboard users have */
:focus { outline: none; }
/* ✅ a strong, visible ring for keyboard focus only */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}The harder half is focus management. When you open a modal, focus must move into it; when it closes, focus must return to the trigger. Otherwise a keyboard or screen-reader user is dumped at the top of the document.
function Modal({ open, onClose, children }: ModalProps) {
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement as HTMLElement;
ref.current?.focus(); // move focus INTO the dialog
} else {
triggerRef.current?.focus(); // return focus to the opener
}
}, [open]);
if (!open) return null;
return (
<div
ref={ref}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={(e) => e.key === "Escape" && onClose()}
>
{children}
</div>
);
}3. Give non-text content a text alternative (1.1.1)
Every image, icon button and chart needs a text equivalent — but the right alternative depends on the role. Decorative images take an empty alt so screen readers skip them; informative images describe the information; icon-only buttons need an accessible name.
// decorative — explicitly empty, so it's skipped
<img src="/texture.png" alt="" />
// informative — describe the information, not the pixels
<img src="/chart.png" alt="Revenue grew 40% from Q1 to Q2" />
// icon-only control — the label IS the accessible name
<button aria-label="Close dialog">
<X aria-hidden="true" />
</button>4. Meet contrast minimums (1.4.3)
- Normal text: at least 4.5:1 against its background.
- Large text (≥24px, or ≥19px bold): at least 3:1.
- UI components and graphical objects (borders, icons, focus rings): at least 3:1 — a commonly missed one.
- Never rely on colour alone to convey meaning (1.4.1): pair a red error with an icon and text, not just a red border.
Placeholder text as a label is a double failure: placeholders usually fail contrast AND vanish on input, leaving no label. Always use a real <label> associated via htmlFor; a placeholder is a hint, never a substitute.
5. Announce dynamic changes (4.1.3)
A sighted user sees a toast appear; a screen-reader user hears nothing unless you tell them. Live regions announce content that changes without a page load — form errors, save confirmations, async results.
// polite: announced when the user is idle (status, success)
<div role="status" aria-live="polite">
{saved && "Changes saved"}
</div>
// assertive: interrupts immediately (use sparingly — errors only)
<div role="alert" aria-live="assertive">
{error}
</div>Test it like a user, not just a linter
- Unplug your mouse. Tab through the whole page — can you reach and operate everything, in a sensible order?
- Turn on the OS screen reader (VoiceOver on macOS, NVDA on Windows) and listen to one real flow.
- Run axe-core in CI to catch regressions, but never mistake a green axe run for 'accessible' — automated tools catch ~40% of issues.
- Zoom to 200% and 400%; content must reflow without horizontal scrolling (1.4.10).
Accessibility done this way costs almost nothing extra — it is mostly choosing the right element and not fighting the platform. Bolted on at the end, it costs a rewrite. The habit that pays off is deciding the semantics, focus behaviour and text alternative while you build the component, not after.