Accessibility in React
Think of it like this: You build a beautiful restaurant, but the front door is too narrow for wheelchairs. The food is amazing, but some customers literally can't get in. That's what an inaccessible website is — a great product that excludes people.
Accessibility (a11y) means building apps that everyone can use — people who are blind, deaf, have motor disabilities, or use assistive technologies. And here's the thing: it's not optional.
What Is ARIA?
ARIA stands for Accessible Rich Internet Applications. It's a set of HTML attributes that tell screen readers and assistive technologies what your UI elements are and what they do.
Think of it like this: HTML gives you a <div>, but a screen reader doesn't know if that <div> is a button, a menu, or a dialog. ARIA attributes are like name tags — they introduce your elements to assistive technologies.
function SearchBox() {
return (
<div role="search" aria-label="Site search">
<input
type="text"
aria-label="Search articles"
placeholder="Search..."
/>
<button aria-label="Submit search">
🔍
</button>
</div>
);
}Without aria-label, a screen reader seeing that button would just say "button" — the user has no idea what it does. With it, the screen reader says "Submit search, button."
The First Rule of ARIA
Common Mistake: Don't use ARIA when native HTML already does the job. A
<button>is already accessible. A<div onClick={...}>is not — and slappingrole="button"on it is a band-aid, not a fix. Use the right HTML element first.
// ❌ Bad — div pretending to be a button
<div role="button" tabIndex={0} onClick={handleClick}>
Click me
</div>
// ✅ Good — just use a button
<button onClick={handleClick}>
Click me
</button>Native HTML elements come with built-in keyboard support, focus management, and screen reader announcements for free. ARIA is for when native HTML isn't enough — like custom widgets.
Common ARIA Attributes
| Attribute | What It Does | Example |
|---|---|---|
role | Tells assistive tech what an element is | role="dialog", role="alert" |
aria-label | Gives an invisible label | aria-label="Close menu" |
aria-labelledby | Points to another element as the label | aria-labelledby="heading-id" |
aria-describedby | Points to a description element | aria-describedby="error-msg" |
aria-hidden | Hides element from screen readers | aria-hidden="true" on decorative icons |
aria-live | Announces dynamic content changes | aria-live="polite" for status updates |
aria-expanded | Tells if a section is open or closed | aria-expanded="true" on accordions |
aria-required | Marks a field as required | aria-required="true" |
Why Accessibility Matters
It's not just about being nice (though that matters too):
1. It's the law. In many countries, inaccessible websites violate disability discrimination laws (ADA in the US, EAA in Europe). Companies get sued — a lot.
2. It's a huge audience. About 15% of the world's population has some form of disability. That's over a billion people.
3. It makes your app better for everyone. Keyboard navigation helps power users. Captions help people in noisy environments. High contrast helps people in bright sunlight. Good accessibility = good UX.
function StatusMessage({ message }: { message: string }) {
return (
<div role="status" aria-live="polite">
{message}
</div>
);
}When message changes, screen readers will announce it automatically because of aria-live="polite". Without this, dynamic updates are completely invisible to screen reader users.
Making Forms Accessible
Forms are where accessibility matters most — and where most developers mess up. Think of it like this: a sighted user can glance at a form and understand it instantly. A screen reader user hears one field at a time, in order, with no visual context. Your code needs to provide that context.
Labels Are Non-Negotiable
function SignupForm() {
return (
<form>
{/* ❌ Bad — screen reader says "edit text, blank" */}
<input type="email" placeholder="Email" />
{/* ✅ Good — screen reader says "Email address, edit text" */}
<label htmlFor="email">Email address</label>
<input id="email" type="email" placeholder="you@example.com" />
</form>
);
}Common Mistake: Using
placeholderinstead of<label>. Placeholders disappear when you start typing, and many screen readers skip them entirely. Always use a visible<label>withhtmlFormatching the input'sid.
Accessible Error Messages
When a form field has an error, sighted users see the red text. Screen reader users need to be told about it explicitly:
function EmailField() {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const validate = () => {
if (!email.includes("@")) {
setError("Please enter a valid email address");
} else {
setError("");
}
};
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={validate}
aria-invalid={error ? "true" : "false"}
aria-describedby={error ? "email-error" : undefined}
/>
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}
</div>
);
}Here's what's happening:
aria-invalid="true"tells the screen reader "this field has an error"aria-describedby="email-error"links the input to its error messagerole="alert"makes the screen reader announce the error immediately when it appears
Focus Management
When something goes wrong, move focus to where the user needs to act. Think of it like a tour guide pointing to the right door instead of making you wander the hallway.
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null);
const [errors, setErrors] = useState<string[]>([]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: string[] = [];
if (!emailRef.current?.value) {
newErrors.push("Email is required");
}
if (newErrors.length > 0) {
setErrors(newErrors);
emailRef.current?.focus();
}
};
return (
<form onSubmit={handleSubmit} noValidate>
{errors.length > 0 && (
<div role="alert" aria-label="Form errors">
<ul>
{errors.map((err) => (
<li key={err}>{err}</li>
))}
</ul>
</div>
)}
<label htmlFor="login-email">Email</label>
<input ref={emailRef} id="login-email" type="email" />
<button type="submit">Log in</button>
</form>
);
}When submission fails, focus jumps to the first problematic field so the user knows exactly where to fix things.
How to Make an Autocomplete Accessible
Building a custom autocomplete is one of the hardest accessibility challenges in web development. A native <select> gives you all of this for free, but when you need a custom dropdown with search, you have to implement it yourself.
Think of it like this: a sighted user sees a text box, types, and sees suggestions appear. A screen reader user needs to know: "I'm in a combo box. There are 5 suggestions. The first one is highlighted. I can press arrow keys to move through them."
The ARIA Pattern: Combobox
function Autocomplete() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const filteredSuggestions = suggestions.filter((s) =>
s.toLowerCase().includes(query.toLowerCase())
);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((prev) =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
);
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0) {
setQuery(filteredSuggestions[activeIndex]);
setIsOpen(false);
setActiveIndex(-1);
}
break;
case "Escape":
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
break;
}
};
const selectSuggestion = (suggestion: string) => {
setQuery(suggestion);
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
};
return (
<div>
<label htmlFor="search-input">Search cities</label>
<div role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
<input
ref={inputRef}
id="search-input"
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
aria-autocomplete="list"
aria-controls="suggestions-list"
aria-activedescendant={
activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
}
/>
{isOpen && filteredSuggestions.length > 0 && (
<ul
ref={listRef}
id="suggestions-list"
role="listbox"
aria-label="Search suggestions"
>
{filteredSuggestions.map((suggestion, index) => (
<li
key={suggestion}
id={`suggestion-${index}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => selectSuggestion(suggestion)}
>
{suggestion}
</li>
))}
</ul>
)}
</div>
<div aria-live="polite" className="sr-only">
{isOpen && filteredSuggestions.length > 0
? `${filteredSuggestions.length} suggestions available`
: ""}
</div>
</div>
);
}Let's break down the key ARIA attributes:
| Attribute | Purpose |
|---|---|
role="combobox" | Tells screen readers this is an input + dropdown combo |
aria-expanded | Whether the dropdown is open or closed |
aria-haspopup="listbox" | Indicates a dropdown will appear |
aria-autocomplete="list" | Tells screen reader suggestions will appear as a list |
aria-controls | Links the input to the suggestions list |
aria-activedescendant | Tells screen reader which suggestion is highlighted |
role="listbox" | The suggestions container is a selectable list |
role="option" | Each suggestion is a selectable option |
aria-selected | Which option is currently highlighted |
aria-live="polite" | Announces the number of results without interrupting |
Keyboard Navigation Patterns
Think of it like this: some users can't use a mouse at all. Everything they do — clicking buttons, opening menus, selecting options — happens through the keyboard. If your widget doesn't work with a keyboard, it doesn't work for them.
Essential Keyboard Patterns
Every interactive element should support these:
function AccessibleTabs() {
const [activeTab, setActiveTab] = useState(0);
const tabs = ["Profile", "Settings", "Notifications"];
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let newIndex = index;
switch (e.key) {
case "ArrowRight":
newIndex = (index + 1) % tabs.length;
break;
case "ArrowLeft":
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
setActiveTab(newIndex);
tabRefs.current[newIndex]?.focus();
};
return (
<div>
<div role="tablist" aria-label="Account settings">
{tabs.map((tab, index) => (
<button
key={tab}
ref={(el) => { tabRefs.current[index] = el; }}
role="tab"
aria-selected={index === activeTab}
aria-controls={`panel-${index}`}
tabIndex={index === activeTab ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab}
id={`panel-${index}`}
role="tabpanel"
aria-labelledby={tab}
hidden={index !== activeTab}
>
Content for {tab}
</div>
))}
</div>
);
}The Keyboard Navigation Cheat Sheet
| Widget | Keys | Behavior |
|---|---|---|
| Buttons | Enter, Space | Activate |
| Links | Enter | Navigate |
| Tabs | Arrow Left/Right | Switch tabs |
| Menus | Arrow Up/Down | Move through items |
| Dialogs | Escape | Close |
| Dropdowns | Arrow Up/Down, Enter | Navigate and select |
| All widgets | Tab | Move to next focusable element |
| All widgets | Shift+Tab | Move to previous focusable element |
Focus Trapping in Modals
When a modal is open, pressing Tab should cycle through elements inside the modal only — not escape to the page behind it.
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement as HTMLElement;
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
} else {
previousFocus.current?.focus();
}
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">Confirm Action</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}Key things happening here:
- Save previous focus — When the modal opens, we remember what was focused before
- Move focus into modal — First focusable element gets focus automatically
- Trap focus — Tab wraps from last to first element (and vice versa with Shift+Tab)
- Restore focus — When the modal closes, focus returns to where it was
- Escape to close — Standard keyboard pattern for dismissing dialogs
Screen Reader Basics
Think of it like this: a screen reader is like having someone read a website to you over the phone. They can only read what's in the HTML — they can't see colors, layout, or visual cues. Your code needs to paint the picture with words.
What Screen Readers Actually Read
// Screen reader says: "navigation, main menu, list with 3 items,
// Home link, About link, current page Contact link"
function Navigation() {
return (
<nav aria-label="Main menu">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact" aria-current="page">Contact</a></li>
</ul>
</nav>
);
}Visually Hidden Text
Sometimes you need text for screen readers that shouldn't be visible:
const srOnlyStyle: React.CSSProperties = {
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
borderWidth: 0,
};
function IconButton({ onClick }: { onClick: () => void }) {
return (
<button onClick={onClick}>
<span aria-hidden="true">🗑️</span>
<span style={srOnlyStyle}>Delete item</span>
</button>
);
}The emoji is hidden from screen readers (decorative), and the visually hidden text provides the label.
Live Regions for Dynamic Content
When content changes dynamically (like notifications, form validation, or loading states), screen readers need to be told:
function NotificationCenter() {
const [notifications, setNotifications] = useState<string[]>([]);
const addNotification = (message: string) => {
setNotifications((prev) => [...prev, message]);
};
return (
<div>
<button onClick={() => addNotification("File saved successfully")}>
Save
</button>
{/* aria-live="polite" waits for a pause before announcing */}
{/* aria-live="assertive" interrupts whatever is being read */}
<div aria-live="polite" aria-atomic="true">
{notifications.length > 0 && (
<p>{notifications[notifications.length - 1]}</p>
)}
</div>
</div>
);
}aria-live value | When to use |
|---|---|
polite | Status updates, search results count, non-urgent info |
assertive | Error messages, warnings, time-sensitive alerts |
off | Default — no announcements |
Common Accessibility Mistakes
These are the mistakes that show up in almost every codebase:
1. Click Handlers on Divs
// ❌ Not accessible — can't be focused, no keyboard support
<div onClick={handleClick}>Click me</div>
// ✅ Accessible — focus, keyboard, and screen reader support built in
<button onClick={handleClick}>Click me</button>2. Missing Alt Text on Images
// ❌ Screen reader says "image" — useless
<img src="/chart.png" />
// ✅ Informative image — describe what it shows
<img src="/chart.png" alt="Sales increased 25% from January to March" />
// ✅ Decorative image — empty alt to skip it
<img src="/decorative-swirl.png" alt="" />Common Mistake: Writing alt text like
alt="image"oralt="photo". The screen reader already says "image" — your alt text should describe what the image shows, not what it is.
3. Poor Color Contrast
// ❌ Light gray text on white background — hard to read
<p style={{ color: "#999", backgroundColor: "#fff" }}>Important info</p>
// ✅ Meets WCAG AA contrast ratio (4.5:1 for normal text)
<p style={{ color: "#595959", backgroundColor: "#fff" }}>Important info</p>4. Auto-Playing Media
// ❌ Starts playing immediately — disorienting for screen reader users
<video autoPlay src="/intro.mp4" />
// ✅ User controls playback
<video controls src="/intro.mp4">
<track kind="captions" src="/captions.vtt" label="English" />
</video>5. Not Managing Focus After Route Changes
In single-page apps, when the route changes, screen reader users don't know the page updated. They're still focused on the link they clicked.
function useAnnounceRouteChange() {
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
setAnnouncement(`Navigated to ${document.title}`);
}, []);
return (
<div
role="status"
aria-live="assertive"
aria-atomic="true"
style={{
position: "absolute",
width: "1px",
height: "1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
}}
>
{announcement}
</div>
);
}6. Forgetting Heading Hierarchy
// ❌ Skipping heading levels confuses screen reader navigation
<h1>My App</h1>
<h3>Welcome</h3> {/* Where's h2? */}
<h5>Features</h5> {/* Where's h4? */}
// ✅ Sequential heading levels create a logical outline
<h1>My App</h1>
<h2>Welcome</h2>
<h3>Features</h3>Screen reader users navigate pages by jumping between headings — it's like a table of contents. Skipping levels breaks that navigation.
Quick Accessibility Checklist
Before shipping any feature, run through this:
| Check | How to Test |
|---|---|
| Can you Tab through everything? | Put your mouse away and use only keyboard |
| Do all images have meaningful alt text? | Inspect images in DevTools |
| Are all form fields labeled? | Check that every input has a <label> |
| Does the color contrast pass? | Use browser DevTools accessibility audit |
| Do dynamic changes get announced? | Turn on a screen reader (VoiceOver on Mac: Cmd+F5) |
| Can you close modals with Escape? | Test keyboard interaction |
| Is focus visible at all times? | Tab through and check for a visible focus ring |
| Does heading hierarchy make sense? | Check with a heading outline tool |
Common Mistake: Removing the default focus outline with
outline: nonefor aesthetic reasons. If you remove it, you must replace it with a custom visible focus indicator. Otherwise, keyboard users can't see where they are on the page.