react-a11y
Accessibility is the single highest-signal differentiator for a Senior UX Engineer. Most candidates ship working code; few ship code that works with a keyboard, screen reader, and reduced motion. Be the latter.
The "first rule of ARIA" — don't use it
Before reaching for role= or aria-*, ask: is there an HTML element that already does this? If yes, use it. The built-in element ships behavior (keyboard, focus, semantics) for free.
| Tempting to write | Use instead |
|---|---|
<div> |
<button> |
<div role="button"> |
<button> |
<a> (no href) |
<button> |
<span> for a label |
<label htmlFor="…"> |
| Custom dropdown div | <select> for simple cases |
<div role="dialog"> |
<dialog> (with caveats) or robust modal pattern |
<div role="list"> |
<ul> / <ol> |
<i className="icon"> for meaningful icons |
Icon <svg> with <title> |
If you find yourself writing role="button", stop and use <button>. The interview reviewer will catch this in 2 seconds.
The pre-flight a11y triage (run before "done")
Before you say done, walk this list in order. 5 minutes.
1. Keyboard only — unplug the mouse
- Tab through every interactive element. Does focus follow visual order?
- Is the focused element always visible (visible focus ring)?
- Esc closes modals/popovers?
- Enter/Space activates buttons?
- Arrow keys navigate composite widgets (tabs, menus, listboxes)?
2. Names — what does a screen reader say?
For each interactive element, the accessible name should be unambiguous in isolation.
- Buttons: have text OR
aria-label. Never icon-only without a label. - Form inputs: have a
<label htmlFor>ORaria-labelledby.placeholderis not a label. - Links: text describes the destination. Never "click here" or "read more" without context.
- Images:
altdescribes intent (decorative →alt="").
3. Landmarks and headings
- One
<h1>per page (or per major region in a single-page app) - Heading levels don't skip (
h2→h3, noth2→h4) - Page has
<main>,<nav>,<footer>(and<header>if appropriate)
4. Contrast
- Text: 4.5:1 (3:1 for large text ≥ 24px / ≥ 19px bold)
- UI components (buttons, inputs borders) and focus indicators: 3:1
- Check both light AND dark mode if applicable
- Use
axeDevTools or the Lighthouse a11y audit
5. Motion
- Wrap non-essential animations in:
@media (prefers-reduced-motion: no-preference) { .animated { animation: slide 0.3s ease; } } - Or in JS:
const prefersReduced = useMediaQuery('(prefers-reduced-motion: reduce)'); - No flashing > 3 times/second (seizure risk)
Focus management patterns
Pattern: Focus on modal open, restore on close
function Modal({ open, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<Element | null>(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement;
dialogRef.current?.focus();
} else if (triggerRef.current instanceof HTMLElement) {
triggerRef.current.focus();
}
}, [open]);
useEffect(() => {
if (!open) return;
const KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal"
>
{children}
</div>,
document.body
);
}
Also needed in production: focus trap (Tab/Shift+Tab stays inside the dialog), scroll lock on body. Use focus-trap-react or roll your own — call this out in the walkthrough.
Pattern: Skip link
Always include for any layout with a nav:
<a href="#main" className="skip-link">Skip to main content</a>
…
<main id="main" tabIndex={-1}>…</main>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 0;
top: 0;
/* visible styles */
}
Pattern: Focus on SPA route change
The single highest-impact a11y move for any client-side router. When a user navigates in an SPA, the DOM updates but focus stays on the old link — screen reader users hear nothing change, keyboard users find themselves halfway down a navigation they've already left. Move focus to the new page's heading.
// In the page-level component (PostDetail, ProductPage, etc.)
function PostDetailPage() {
const { data } = useQuery(...);
const titleRef = useRef<HTMLHeadingElement>(null);
// Move focus to the title when the page's primary data arrives
useEffect(() => {
if (data && titleRef.current) titleRef.current.focus();
}, [data]);
return (
<article>
<h1
ref={titleRef}
tabIndex={-1} // makes a non-interactive element focusable
className="outline-none" // we still want :focus-visible, just not a click-focus ring
>
{data?.title}
</h1>
{/* ... */}
</article>
);
}
Why tabIndex={-1}: an <h1> isn't focusable by default. -1 makes it programmatically focusable without inserting it into the tab order. Screen readers will read the heading when focus lands on it.
Why outline-none (with :focus-visible left intact elsewhere): a click-triggered focus on a heading is visual noise. Keep the global :focus-visible ring for actual keyboard interactions.
The dependency on data (rather than route params) means focus moves when content actually arrives, not before the loading spinner.
For loaders/Suspense-based routers, a wrapper component that focuses on mount works:
function PageHeading({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
return <h1 ref={ref} tabIndex={-1} className="outline-none">{children}</h1>;
}
Don't combine this with scroll-restoration logic — let the router's ScrollRestoration (React Router) handle scroll, and let focus handle screen-reader announcement separately.
Pattern: Roving tabindex (tabs, menus, listbox)
Only one element in the group is tabbable at a time; arrow keys move focus:
function Tabs({ items }: TabsProps) {
const [active, setActive] = useState(0);
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const KeyboardEvent<HTMLButtonElement>, i: number) => {
if (e.key === 'ArrowRight') {
const next = (i + 1) % items.length;
setActive(next);
refs.current[next]?.focus();
} else if (e.key === 'ArrowLeft') {
const prev = (i - 1 + items.length) % items.length;
setActive(prev);
refs.current[prev]?.focus();
}
};
return (
<div role="tablist">
{items.map((item, i) => (
<button
key={item.id}
ref={el => { refs.current[i] = el; }}
role="tab"
aria-selected={active === i}
tabIndex={active === i ? 0 : -1}
=> setActive(i)}
=> onKey(e, i)}
>
{item.label}
</button>
))}
</div>
);
}
Form a11y patterns
Every input has a real label
// Good
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// Also good (label wraps)
<label>
Email
<input type="email" />
</label>
// Acceptable when label is visual elsewhere
<input aria-label="Search" type="search" />
Errors are programmatically associated
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && (
<p id="email-error" role="alert">
{error}
</p>
)}
role="alert" announces the error when it appears. For multi-field forms, prefer a single <div role="alert" aria-live="polite"> summary at the top of the form so the user hears all errors at once.
Required fields
<label htmlFor="name">
Name <span aria-hidden="true">*</span>
</label>
<input id="name" required aria-required="true" />
Or describe the convention once in form intro:
<p>Fields marked with <span aria-hidden="true">*</span> are required.</p>
Submit feedback (live region)
<div role="status" aria-live="polite">
{submitting && 'Submitting your request…'}
{submitted && 'Form submitted successfully.'}
</div>
Image and icon a11y
// Decorative — empty alt
<img src="/swirl.svg" alt="" />
// Meaningful image — describe intent, not pixels
<img src="/team.jpg" alt="The Netlify marketing team on stage at JamstackConf" />
// Icon with no accompanying text → label it
<button aria-label="Close">
<XIcon aria-hidden="true" />
</button>
// Icon next to text → hide from screen reader (text wins)
<button>
<DownloadIcon aria-hidden="true" />
Download
</button>
For decorative <svg> always include aria-hidden="true". For meaningful SVG include <title> as first child or use aria-label on the svg.
Color and contrast
- Never convey state by color alone — pair with icon, text, or shape
- Error text + red color + error icon — not just red
- Focus rings: use both color and a 2px+ ring; ensure 3:1 contrast against adjacent surfaces
- For text on images, use overlay/scrim to guarantee contrast
prefers-reduced-motion
For any motion: opacity, transform, transition, animation, scroll-triggered effect:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
This is a Senior UX Engineer signal — most candidates forget it.
ARIA gotchas (the easy mistakes)
aria-labelon an element with visible text — the label overrides the text (screen reader users hear only the label)role="presentation"on something interactive — strips semanticsaria-hidden="true"on something focusable — screen reader users tab to nothingaria-expandedwithoutaria-controls(or vice versa) — partial wiringaria-live="polite"on a region that doesn't exist yet at announcement time — it must already be in the DOMtabindex> 0 — never use; it breaks tab order
Testing tools (mention in walkthrough)
- axe DevTools (browser extension): finds 30-40% of automatable issues
- Lighthouse Accessibility audit
- Keyboard test: hands off mouse, tab through everything
- Screen reader test: VoiceOver (Cmd+F5 on Mac), NVDA on Windows
- Color contrast: Chrome DevTools picker, axe, WebAIM contrast checker
"I ran axe and Lighthouse; both clean. I tabbed through with the keyboard and the focus order matches reading order. For the modal, I'd add a real focus trap library in production — for the time-boxed build I shipped Esc-to-close and initial focus."
Quick reference — WCAG 2.2 AA must-haves
- 1.1.1 Non-text content has alt text
- 1.3.1 Info and relationships preserved by semantic HTML
- 1.4.3 Text contrast 4.5:1
- 1.4.11 Non-text contrast 3:1 (UI components)
- 2.1.1 All functionality available from keyboard
- 2.4.3 Focus order is logical
- 2.4.7 Focus is visible
- 2.5.5 Target size 24×24px min (AA), 44×44px (AAA, but worth aiming for)
- 3.3.2 Form fields have labels
- 4.1.2 Custom controls have name, role, value
Narration phrases
- "I started with semantic HTML —
<button>and<form>give me keyboard handling and focus for free." - "For the modal, I capture focus on open and restore it on close. In production I'd add a real focus trap library."
- "I added a live region for the form errors so screen reader users hear them immediately."
- "I wrapped the slide animation in
prefers-reduced-motion: no-preference— not everyone wants motion." - "I used
aria-currentfor the active nav item — it's announced as 'current page' which is more meaningful than visual styling alone."