About one in five people has a disability that affects how they use the web: low vision, colour blindness, deafness, motor difficulties, cognitive differences. Others have a broken arm, a bright screen or a slow connection. Accessibility (often written a11y) means building so that all of them can use your product. It is also good engineering, part of many laws and contracts, and helps search engines. React does not make this automatically better or worse; it depends on the HTML you produce. This lesson covers the habits that matter most.
1. Start with semantic HTML
Browsers and assistive technologies understand HTML elements: a <button> is focusable, activates with Enter and Space, and is announced as "button". A <nav> is a navigation landmark. A heading structure lets screen reader users jump around. Use the element that matches the meaning, and you get all of that without writing code.
function App() {
return (
<div>
<header>
<h1>My shop</h1>
<nav aria-label="Main">
<a href="#products">Products</a> | <a href="#cart">Cart</a>
</nav>
</header>
<main>
<h2 id="products">Products</h2>
<ul>
<li>Notebook <button type="button">Add to cart</button></li>
<li>Pencil <button type="button">Add to cart</button></li>
</ul>
<h2 id="cart">Cart</h2>
<p>Your cart is empty.</p>
</main>
</div>
);
}
export default App;My shop
Products | Cart
Products
Notebook Add to cart
Pencil Add to cart
Cart
Your cart is empty.Check the structure: one h1, headings that follow in order (h2 under h1, no jumping from h1 to h4 to get a smaller font; style with CSS instead), lists for lists, header, nav and main landmarks, links (a) that go somewhere and buttons that do something. Fragments (<>...</>) help here too, since they let components return several elements without extra wrapper divs that break the structure.
Do not turn divs into buttons
This is the commonest React accessibility bug:
// Bad: not focusable, no keyboard activation, not announced as a button
<div className="btn" onClick={save}>Save</div>
// Good
<button type="button" onClick={save}>Save</button>A div with onClick works only for mouse users. To make it work for everyone you would have to add role="button", tabIndex={0}, a key handler for Enter and Space, and focus styles, which is exactly what <button> already is. Style the real button with CSS to look however you like.
2. Label every form control
Every input needs a label that is programmatically linked to it: screen readers read it when the field is focused, and clicking the label focuses the input (a bigger target for everyone). In JSX, for is written htmlFor. You can wrap the input inside the label or link them with matching ids. Hard-coded ids break when a component appears twice, so generate them with useId:
import { useId } from "react";
function TextField({ label, hint }) {
const id = useId(); // unique per component instance
return (
<p>
<label htmlFor={id}>{label}</label><br />
<input id={id} aria-describedby={hint ? id + "-hint" : undefined} />
{hint && <small id={id + "-hint"}> {hint}</small>}
</p>
);
}
function App() {
return (
<form>
<TextField label="Email" hint="We never share it." />
<TextField label="Name" />
<TextField label="City" />
</form>
);
}
export default App;Email
We never share it.
Name
CityuseId returns an id that is unique for each call and stable between the server and the client. Three TextFields on one page therefore each get a correct label. aria-describedby links the hint text so it is read after the label. A placeholder is not a label: it disappears when typing and has poor contrast. For errors, use the pattern from the forms lesson: aria-invalid, aria-describedby pointing at the message, and role="alert".
3. Everything must work with the keyboard
Many users never touch a mouse: they use Tab, Shift+Tab, Enter, Space, arrow keys and Escape. Test your app by putting the mouse away:
- Can you reach every control with Tab, in a logical order?
- Is the focused element visible? Never write
outline: nonewithout a replacement style. - Do menus, tabs and dialogs respond to the keys users expect (Escape closes a dialog)?
- Avoid
tabIndexvalues above 0, which scramble the natural order. UsetabIndex={0}to make a custom element focusable andtabIndex={-1}to allow focusing by code only.
Native elements follow these rules already; custom widgets must reproduce them, following the WAI-ARIA Authoring Practices patterns.
4. Manage focus when the screen changes
In a single-page app, content appears and disappears without the browser reloading, so the browser does not move the user's focus for you. When you open a dialog, reveal a section, or move to a new "page", you must move focus somewhere sensible, using refs (from the refs lesson), and restore it afterwards:
import { useEffect, useRef, useState } from "react";
function App() {
const [open, setOpen] = useState(false);
const headingRef = useRef(null);
useEffect(() => {
if (open) headingRef.current.focus(); // move focus into the panel when it opens
}, [open]);
return (
<div>
<button className="open" onClick={() => setOpen(true)}>Open settings</button>
{open && (
<section aria-labelledby="settings-title">
<h2 id="settings-title" ref={headingRef} tabIndex={-1}>Settings</h2>
<p>Focus moved to this heading, so a screen reader announces it.</p>
<button onClick={() => setOpen(false)}>Close</button>
</section>
)}
</div>
);
}
export default App;Open settings
Settings
Focus moved to this heading, so a screen reader announces it.
CloseThe heading has tabIndex={-1}, so code can focus it, though it is not in the Tab order. For a "close" action, return focus to the button that opened the panel (save it in a ref). When the route changes, a common practice is to focus the new page's h1.
The native dialog element
Modal dialogs are hard to get right by hand (trapping focus inside, hiding the background from screen readers, closing with Escape). The HTML <dialog> element does all of that when opened with showModal(). Use a ref to call it:
import { useRef } from "react";
function App() {
const dialogRef = useRef(null);
return (
<div>
<button className="open" onClick={() => dialogRef.current.showModal()}>Delete account</button>
<dialog ref={dialogRef} aria-labelledby="dlg-title">
<h2 id="dlg-title">Delete your account?</h2>
<p>This cannot be undone.</p>
<form method="dialog">
<button value="cancel">Cancel</button>{" "}
<button value="delete">Delete</button>
</form>
</dialog>
</div>
);
}
export default App;Delete account
Delete your account?
This cannot be undone.
Cancel DeleteshowModal() opens it with a backdrop, moves focus inside, makes the rest of the page inert and closes it on Escape. A form with method="dialog" closes it when a button is pressed, and the pressed button's value is reported in dialog.returnValue. This is much less code than a hand-made modal, and modern browsers all support it.
5. Announce changes with live regions
Sighted users see a "Saved" message appear. Screen reader users hear nothing unless the update is announced. An element with aria-live="polite" (or role="status") is monitored, and changes to its text are read out without stealing focus. Use role="alert" (assertive) only for urgent errors. The region must exist before its content changes:
import { useState } from "react";
function App() {
const [items, setItems] = useState(0);
return (
<div>
<button className="add" onClick={() => setItems(items + 1)}>Add to cart</button>
<p role="status">{items === 0 ? "" : items + " item" + (items === 1 ? "" : "s") + " in the cart"}</p>
</div>
);
}
export default App;Add to cart
2 items in the cartA screen reader would announce "2 items in the cart" after the second press. Do the same for search result counts, "loading" states, and form submit outcomes.
6. Images, colour and motion
- Images: every
<img>needs analtattribute. Describe the purpose, not the file ("Asha Rao smiling"). Usealt=""for purely decorative images so that they are skipped. Icons inside a button need an accessible name through text oraria-label. - Colour: do not use colour alone to convey meaning (an error that is only red). Text needs enough contrast against its background: at least 4.5:1 for normal text under WCAG. The Image Color Picker and your browser's inspector can help check colours.
- Motion: respect
prefers-reduced-motionin CSS, and avoid auto-playing animation. - Zoom and text size: use relative units (
rem) so that users can enlarge text, and make sure layouts work at 200% zoom. - Language: set
langon the<html>element (the Vite template does:lang="en").
7. Tools and testing
Automatic tools find about a third of the problems, but they are cheap and catch the obvious ones:
eslint-plugin-jsx-a11ywarns about missingalttext,divclick handlers, wrong ARIA and more, as you type.- axe DevTools (browser extension), Lighthouse (in Chrome DevTools) and
@axe-core/reactorvitest-axein tests check the rendered page. - Keyboard test: tab through the whole page.
- Screen reader test: try VoiceOver (macOS and iOS), NVDA (Windows, free) or TalkBack (Android) on your key flows.
- Testing Library queries such as
getByRole('button', { name: 'Save' })only succeed when elements have the right roles and names, so well-written tests double as accessibility checks. The testing lesson shows how.
A short checklist
- Right element for the job (button, link, heading, list, landmarks).
- Every control has a label; errors are linked and announced.
- Everything works with the keyboard; focus is always visible.
- Focus moves sensibly when content opens, closes or changes page.
- Dynamic updates are announced in live regions.
- Images have
alt; contrast is sufficient; motion is optional. - You tested with a keyboard and a screen reader, not only with tools.
Common mistakes
- Using
divorspanwithonClickinstead ofbuttonora. - Removing the focus outline.
- Placeholders instead of labels.
- Duplicated hard-coded ids because the component appears more than once.
- Opening dialogs without moving focus into them or restoring it after.
- Updating text silently with no live region.
- Missing
alt, oralt="image". - Using
aria-*attributes where plain HTML would do. The first rule of ARIA is not to use ARIA if a native element can do the job.
Exercise
- Tab through a page you built earlier. List the five worst problems you find.
- Replace any
divclick handlers withbuttonelements and restyle them. - Build an accessible tab list (
role="tablist",tab,tabpanel) with arrow-key navigation, following the WAI-ARIA Authoring Practices. - Add a live region that announces "3 results found" when a search box filters a list.
Key points
- Use the right HTML element first: buttons for actions, links for navigation, labels for fields, headings in order. Native elements bring keyboard and screen reader support for free.
- Link every form control to a label with htmlFor, and generate unique ids with useId so that components stay reusable.
- Every interactive thing must work with the keyboard, and when content appears or moves, manage focus with refs and announce changes with aria-live.
- Check your work with a linter (jsx-a11y), a browser tool such as axe, the keyboard, and a screen reader.