Accessibility in React
Accessibility (often shortened to a11y) means people can use your app however they use a computer: with a keyboard instead of a mouse, with a screen reader that speaks the page aloud, with zoomed text or with a shaky hand on a small phone. Government and many company projects in India must meet accessibility standards, and an accessible app is simply a better app for everyone.
React renders ordinary HTML, so most accessibility comes from writing good HTML in your JSX. But single-page apps add their own problems: content changes without a page load, focus gets lost when elements disappear, and custom widgets need keyboard support. This lesson covers those React-specific parts. For the full background (WCAG, screen readers, Indian law), see the Web Accessibility tutorial.
Start with the right elements
The first rule is to use the HTML element that already does the job. A <button> can be focused with Tab, pressed with Enter or Space, and is announced as "button". A <div onClick> can do none of this unless you add it all yourself. In JSX a few attribute names differ from HTML, but accessibility attributes do not:
| HTML | JSX | Note |
|---|---|---|
for="email" | htmlFor="email" | Connects a <label> to its input |
tabindex="0" | tabIndex={0} | Camel case, number value |
aria-expanded="true" | aria-expanded={open} | All aria-* names stay exactly as in HTML |
role="tablist" | role="tablist" | Unchanged |
Boolean values such as aria-expanded={open} are written to the page as the strings "true" and "false", which is what screen readers expect. Give every image an alt (empty alt="" for decorative images), every input a label, and every icon-only button an aria-label.
Accessible form errors
A sighted user sees a red border; a screen-reader user needs the error connected to the field. Three attributes do this: aria-invalid marks the field as wrong, aria-describedby points to the id of the error text so it is read with the field, and moving focus to the first wrong field takes the user straight to the problem. useId creates unique ids, so the component can be used twice on one page:
import { useId, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
function Field({ label, error, inputRef, ...inputProps }) {
const id = useId();
const errorId = `${id}-error`;
return (
<p>
<label htmlFor={id}>{label}</label>{" "}
<input id={id} ref={inputRef} aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined} {...inputProps} />
{error && <span id={errorId} style={{ color: "#b91c1c" }}> {error}</span>}
</p>
);
}
function AdmissionForm() {
const [errors, setErrors] = useState({});
const nameRef = useRef(null);
const phoneRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const found = {};
if (String(data.get("name")).trim().length < 3) found.name = "Enter your full name.";
if (!/^[6-9]\d{9}$/.test(String(data.get("phone")))) found.phone = "Enter a 10-digit mobile number.";
setErrors(found);
const firstBad = found.name ? nameRef : found.phone ? phoneRef : null;
if (firstBad) {
firstBad.current.focus();
console.log(`Focus moved to: ${firstBad.current.labels[0].textContent}`);
} else {
console.log("Form sent");
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<Field label="Student name" name="name" inputRef={nameRef} error={errors.name} />
<Field label="Mobile" name="phone" inputMode="numeric" inputRef={phoneRef} error={errors.phone} />
<button>Apply</button>
</form>
);
}
createRoot(document.getElementById("root")).render(<AdmissionForm />);Type a name such as "Isha Nair" and leave the mobile empty, then press "Apply". The error appears and the console prints:
Focus moved to: Mobile
A screen reader now announces something like "Mobile, invalid entry, edit text, Enter a 10-digit mobile number." noValidate turns off the browser's own popups so your messages are used consistently. For long forms, many teams also show an error summary at the top with links to each field.
Dialogs and focus
Focus is where keyboard input goes. When you open something, focus should move into it; when you close it, focus should return to where the user was. The native <dialog> element, opened with showModal(), does most of this for you: it moves focus inside, keeps the rest of the page from being reached, closes with Escape, and returns focus to the button that opened it. In React you call showModal() through a ref:
import { useRef, useState } from "react";
import { createRoot } from "react-dom/client";
function DeleteOrder({ orderNo }) {
const dialogRef = useRef(null);
const cancelRef = useRef(null);
const [status, setStatus] = useState("");
function open() {
dialogRef.current.showModal();
cancelRef.current.focus(); // start on the safer choice
}
function close(result) {
dialogRef.current.close();
setStatus(result);
setTimeout(() => console.log(`Focus is back on: ${document.activeElement.textContent}`), 0);
}
return (
<>
<button onClick={open}>Delete order {orderNo}</button>
<dialog ref={dialogRef} aria-labelledby="confirm-title" onClose={() => console.log("Dialog closed")}>
<h2 id="confirm-title">Delete order {orderNo}?</h2>
<p>This cannot be undone.</p>
<button onClick={() => close("Order deleted")}>Delete</button>{" "}
<button ref={cancelRef} onClick={() => close("Kept")}>Cancel</button>
</dialog>
<p role="status">{status}</p>
</>
);
}
createRoot(document.getElementById("root")).render(<DeleteOrder orderNo="JP-4471" />);Open the dialog and press "Cancel". The console prints:
Dialog closed Focus is back on: Delete order JP-4471
By default showModal() focuses the first focusable element inside, here "Delete". For a destructive action it is kinder to start on "Cancel", so open moves focus there with a second ref. (React's autoFocus prop does not help here: React focuses the element when it first mounts, and this dialog is mounted, closed, from the start.) aria-labelledby gives the dialog its name from the heading. The paragraph with role="status" is a live region: when its text changes, screen readers announce it politely without moving focus. A live region must already be on the page before its text changes, so render the empty element and fill it later, as here. Use role="alert" only for urgent messages, because it interrupts the user.
Keyboard support for custom widgets
When no native element exists, as with tabs, you must add keyboard behaviour yourself. The usual pattern for tabs is a roving tabindex: only the selected tab has tabIndex={0}, so Tab moves into the tab list once, and the arrow keys move between tabs:
import { useRef, useState } from "react";
import { createRoot } from "react-dom/client";
const tabs = ["Overview", "Syllabus", "Fees"];
function CourseTabs() {
const [active, setActive] = useState(0);
const tabRefs = useRef([]);
function handleKeyDown(e) {
let next = active;
if (e.key === "ArrowRight") next = (active + 1) % tabs.length;
else if (e.key === "ArrowLeft") next = (active - 1 + tabs.length) % tabs.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = tabs.length - 1;
else return;
e.preventDefault();
setActive(next);
tabRefs.current[next].focus();
console.log(`${e.key}: ${tabs[next]}`);
}
return (
<div>
<div role="tablist" aria-label="BCA course" onKeyDown={handleKeyDown}>
{tabs.map((name, i) => (
<button key={name} role="tab" id={`tab-${i}`} aria-selected={i === active} aria-controls={`panel-${i}`}
tabIndex={i === active ? 0 : -1} ref={(el) => { tabRefs.current[i] = el; }}
onClick={() => setActive(i)}>
{name}
</button>
))}
</div>
<div role="tabpanel" id={`panel-${active}`} aria-labelledby={`tab-${active}`} tabIndex={0}>
{active === 0 && "Three-year degree at a college in Lucknow."}
{active === 1 && "C, Java, DBMS, Web Development, Python."}
{active === 2 && "₹38,000 per year."}
</div>
</div>
);
}
createRoot(document.getElementById("root")).render(<CourseTabs />);Click "Overview" to focus it, then press the right arrow twice and Home:
ArrowRight: Syllabus ArrowRight: Fees Home: Overview
The ref callback stores each button in an array, so the handler can move real browser focus, not only the highlight. Libraries such as React Aria and Radix UI ship tested versions of tabs, menus and comboboxes; for complex widgets, using one of them is often safer than writing your own.
Route changes and page titles
In a normal website every link loads a new page, and screen readers announce its title. In a single-page app the URL changes but nothing is announced, and focus may stay on a link that no longer exists. After navigation, update the title and move focus to the new page's main heading. React 19 lets any component render a <title>, which React places in the document head:
function OrdersPage() {
const headingRef = useRef(null);
useEffect(() => {
headingRef.current.focus(); // tell screen-reader users the page changed
}, []);
return (
<main>
<title>My orders | Kirana Express</title>
<h1 ref={headingRef} tabIndex={-1}>My orders</h1>
</main>
);
}tabIndex={-1} lets code focus the heading without adding it to the Tab order. See Single-Page Apps & Route Changes for other approaches.
Common mistakes
- Clickable
divs andspans. They cannot be focused or pressed with the keyboard. Use<button>for actions and<a>/<Link>for navigation. - Placeholder instead of a label. Placeholders vanish when typing starts and are often not read. Use a real
<label>. - Losing focus when an element disappears. After deleting an item or closing a panel, move focus somewhere sensible.
- Adding a live region at the same moment as its text. Many screen readers ignore it; render the empty region first.
- Removing focus outlines.
outline: nonewithout a visible replacement leaves keyboard users lost. - ARIA instead of HTML.
role="button"on adivstill needs keyboard code; a real button needs none.
Key points
- Most accessibility is good HTML: real buttons, links, labels, headings and
alttext. - In JSX use
htmlForandtabIndex; allaria-*attributes keep their HTML names. - Connect errors with
aria-invalidandaria-describedby, and move focus to the first wrong field. - Open dialogs with
<dialog>andshowModal()through a ref; focus moves in and returns on close. - Live regions (
role="status") announce updates; they must exist before their text changes. - Custom widgets need keyboard support, such as a roving tabindex for tabs.
- After route changes, update the
<title>and move focus to the new page's heading.
Exercise
Build an accessible "Notify me" form for an out-of-stock phone. It has one email input with a proper label, a submit button, and a role="status" paragraph that is always rendered. When the email does not contain "@", show an error connected with aria-describedby, set aria-invalid, and move focus to the input. When it is valid, clear the error and put "We will email you when it is back in stock." in the status paragraph.
Show solution
import { useId, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
function NotifyMe() {
const id = useId();
const inputRef = useRef(null);
const [error, setError] = useState("");
const [status, setStatus] = useState("");
function handleSubmit(e) {
e.preventDefault();
const email = String(new FormData(e.currentTarget).get("email")).trim();
if (!email.includes("@")) {
setError("Enter an email address such as name@example.com.");
setStatus("");
inputRef.current.focus();
return;
}
setError("");
setStatus("We will email you when it is back in stock.");
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor={id}>Email</label>{" "}
<input id={id} name="email" type="email" ref={inputRef}
aria-invalid={error ? true : undefined} aria-describedby={error ? `${id}-err` : undefined} />
<button>Notify me</button>
{error && <p id={`${id}-err`} style={{ color: "#b91c1c" }}>{error}</p>}
<p role="status">{status}</p>
</form>
);
}
createRoot(document.getElementById("root")).render(<NotifyMe />);The status paragraph is rendered from the start, so screen readers announce the success message when its text appears. The error is linked to the input, and focus goes straight to the field that needs fixing.
Quick Quiz
1. How do you write the HTML attribute for="city" on a label in JSX?
for is a JavaScript keyword, so JSX uses htmlFor; aria-* attributes keep their names.
2. What does aria-describedby={errorId} on an input achieve?
It links the input to the element with that id, so its text becomes the field's description.
3. Why should a live region be rendered before its message changes?
Render the empty role="status" element first, then change its text.
4. With a roving tabindex, which tab has tabIndex={0}?
One Tab stop for the whole tab list, with arrow keys moving between tabs, matches the expected keyboard pattern.
Learn React with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
