Tutorials
Building Accessible React Components
Lighthouse can score your homepage at 96 and you still ship a mobile menu that traps focus incorrectly. Automated audits catch contrast ratios and missing labels. They miss conditional ARIA that never renders, dialogs without role="dialog", and buttons that look like links.
We ran a WCAG 2.1 AA audit on the Null World Productions marketing site in June 2026. The fixes were small in line count and large in user impact. This guide collects the React patterns we now standardize on.
Start with semantics, not attributes
ARIA repairs broken HTML. It does not replace it.
Use the native element that matches behavior:
<button>for actions that stay on the page<a href="...">for navigation<nav>for primary navigation regions<main>,<header>,<footer>for page landmarks
A <div onClick={...}> styled as a button fails keyboard support unless you rebuild what the browser gives you for free. Prefer <button type="button"> and reset styles in CSS.
Heading order matters for screen readers. One <h1> per page. Section titles step down (h2, then h3) without skipping levels for styling convenience.
Keyboard navigation you can test in five minutes
Tab through every interactive control on the page. Ask:
- Can I reach everything with the keyboard alone?
- Is focus visible on every focused element?
- Does tab order follow reading order?
- Does Escape close overlays that should close?
For the mobile navigation menu on our site, we added:
role="dialog"andaria-modal="true"when openaria-labeldescribing the dialog purpose- Escape to close
- Focus trap while open (Tab cycles inside the menu)
- Return focus to the menu button on close
Focus trap implementation sketch:
useEffect(() => {
if (!isOpen) return;
const menu = menuRef.current;
if (!menu) return;
const focusable = menu.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
first?.focus();
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
setIsOpen(false);
return;
}
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen]);
ARIA patterns that broke in production
Conditional spread attributes. This pattern failed us:
<div
aria-hidden={!isOpen}
{...(isOpen && { role: 'dialog', 'aria-modal': 'true' })}
/>
When isOpen is false, the dialog attributes disappear, which is correct. When true, the spread sometimes did not apply consistently across builds. We switched to explicit attributes:
<div
id="mobile-menu"
role={isOpen ? 'dialog' : undefined}
aria-modal={isOpen ? true : undefined}
aria-hidden={!isOpen}
aria-label={isOpen ? 'Mobile navigation menu' : undefined}
>
aria-hidden with focusable children. Never hide a subtree from assistive technology while a link or button inside it can still receive focus. Either remove focusable elements from the tab order when hidden (tabIndex={-1} and inert where supported) or do not use aria-hidden on an ancestor of focused content.
Icon-only buttons. Every icon button needs an accessible name:
<button type="button" aria-label="Open navigation menu" aria-expanded={isOpen}>
<MenuIcon aria-hidden="true" />
</button>
Mark decorative icons with aria-hidden="true" so screen readers skip them.
Forms and validation
Associate labels with inputs:
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
Surface errors with aria-describedby pointing at the error element id. Set aria-invalid="true" when validation fails.
Do not rely on color alone for error states. Pair color with text and an icon that has a text alternative or is marked decorative.
Motion and contrast
Respect prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Check text contrast against WCAG AA (4.5:1 for normal text, 3:1 for large text). Our brand audit flagged muted body copy on dark backgrounds; we adjusted token values rather than overriding per component.
Component checklist before merge
Run this on every new React component:
- Correct native element or documented reason for a custom widget
- Keyboard operable; visible focus ring
- Accessible name on every control (visible label or
aria-label) - Dialogs: focus trap, Escape, focus restore
- No
aria-hiddenon ancestors of focused elements - Automated scan (Lighthouse, axe) on the page that uses the component
Testing stack
We combine three layers:
- Lighthouse in CI or locally on key routes (target ≥95 on marketing pages)
- Automated accessibility checks (e.g. axe) on component tests for regressions
- Manual keyboard pass on anything with overlays, carousels, or custom widgets
Automated tools find about 30–40% of issues in our experience. The rest show up when you tab through the UI like a user who never touches a mouse.
Related Resources:
Related Articles:
Need help? Contact us for web application delivery with accessibility built in from the start.
Read Next
View all postsPostgreSQL for Analytics: Best Practices
Keep OLTP and analytics paths separate, pre-aggregate what dashboards repeat, and index for the filters users actually touch. Patterns we use on BI and ETL projects.
Building Your First Analytics Dashboard
Start with three metrics, one grain, and a schema you can query without heroics. A practical path from spreadsheet exports to a dashboard small teams can maintain.