Originally published at A11yFix.
We've spent the last few months going through React codebases — open source, client work, our own old projects we'd rather not look at — and there's a pattern. The accessibility bugs aren't random. They're the same five or six bugs, in roughly the same proportions, in almost every codebase. And once you start looking for it, the source becomes obvious: they're tutorial residue.
Not because tutorials are bad. React tutorials are some of the best learning material on the web. But a tutorial has to fit in 12 minutes or 800 words, and the first thing that gets cut for time is the part where the component actually has to work for a human who isn't holding a mouse. The author knows. They almost always say "in production you'd want to handle X" and then move on. Readers don't. Readers copy the example into their app and ship it.
So this isn't a "React tutorials are bad" piece. It's a "here's what gets skipped, and here's the 30 seconds of extra code that fixes it" piece. We've seen each of these in almost every codebase we've touched, so if you recognize one, you're not alone — it's basically the default state.
1. The clickable div
If one pattern defines tutorial-culture React, it's <div onClick={...}>.
It's everywhere because it's natural in JSX. You're already writing divs for layout, you already have an onClick handler in scope, and the result looks correct. Visually, a styled div with a click handler is indistinguishable from a button. To axe-core, to keyboard users, and to screen readers, it isn't.
A <div> has no role, no focus ring, no Enter/Space activation, and doesn't appear in the accessibility tree as something interactive. A keyboard user literally cannot reach it. Axe-core flags this as nested-interactive or interactive-supports-focus depending on how you nested it, but the cleaner rule is: if a thing does something on click, it is a button or a link, full stop.
The fix is one word:
// before
<div className="card" onClick={handleSelect}>...</div>
// after
<button type="button" className="card" onClick={handleSelect}>...</button>
You'll need type="button" (otherwise inside a form it'll submit — the other thing tutorials skip) and you'll need to reset some browser styles, but that's a tailwind class, not a refactor. If your <div> is genuinely a navigation target — going to a new URL — make it an <a href> instead. Don't useNavigate from a click handler on a div.
2. The mystery icon button
Once you've replaced your divs with buttons, the next pattern shows up immediately: buttons with only an icon inside them. A gear emoji for settings. A trash can for delete. A magnifier for search. These look great in a tutorial screenshot and they ship in production with no accessible name at all.
Screen readers will announce them as "button" with nothing after it. That's the entirety of the information the user gets. In a row of icon buttons — which is exactly where this pattern lives — you get "button, button, button" and you have no idea which one will delete your data.
Axe-core calls this button-name, and it's in the top three most common violations on every codebase we've audited. The fix is aria-label:
<button type="button" aria-label="Delete row" onClick={onDelete}>
<TrashIcon aria-hidden="true" />
</button>
Two things matter here. First, aria-hidden="true" on the icon — otherwise some screen readers will try to announce the icon's own label and you'll get a mess. Second, the label should describe the action, not the icon. "Delete row" not "Trash can." Nobody cares what shape the icon is.
If you've got an emoji as the icon (a gear character for settings is the canonical example), the rule is exactly the same: wrap it in a <span aria-hidden="true">, put the real label on the button.
3. The label that's just text
Most React form tutorials show forms like this:
<div>
<p>Email</p>
<input type="email" value={email} onChange={...} />
</div>
This is a label in the visual sense and not in any other sense. There's no association between the <p> and the <input>. Click the word "Email" — nothing focuses. A screen reader landing on the input announces "edit, blank" with no idea what it's editing. Axe-core flags it as label.
The HTML answer is a <label htmlFor> paired with an input id:
<label htmlFor="email">Email</label>
<input id="email" type="email" value={email} onChange={...} />
In React this gets awkward because IDs need to be unique and you're often rendering the same form in multiple places, so use useId():
const id = useId();
return (
<>
<label htmlFor={id}>Email</label>
<input id={id} type="email" />
</>
);
useId() exists specifically for this. It's been in React since 18 and it's still rare to see in tutorial code, probably because adding it to the example makes the snippet two lines longer. We know how this goes.
If you're using a form library, check whether your <Input> component accepts an id prop and threads it through. A surprising number of design systems drop the id on the floor between the wrapper and the input. Worth a 5-minute audit of your own component library.
For more on the underlying ARIA model and when you'd reach for aria-label vs <label>, our ARIA attributes beginner's guide walks through the trade-offs.
4. Modals that trap nothing
Every React tutorial that covers portals uses a modal as the example. The modal example is usually correct in the sense that it renders into a portal and closes when you click the X. It's almost never correct in the sense that a keyboard user can use it.
Three things are missing in the typical tutorial modal:
The first is focus trap. When the modal opens, focus should move into it, and Tab should not let you escape back into the page underneath. Otherwise a screen reader user opens a modal and immediately Tab-keys their way back into the now-hidden page content, with no idea the modal is even there.
The second is Escape. Escape should close the modal. This is muscle memory for every keyboard user on the planet. Tutorials skip it because adding a keydown listener and cleaning it up in useEffect doubles the size of the example.
The third is restoring focus on close. When the modal closes, focus should go back to whatever opened it — usually the button the user clicked. Otherwise focus lands on <body> and the next Tab dumps the user at the top of the page.
If you don't want to write all this yourself — and honestly, you shouldn't, because there are subtle bugs around inert content and aria-hidden — use a primitives library. Radix UI, React Aria, Headless UI, Ark UI all give you a <Dialog> that does focus trap, Escape, and focus restoration out of the box. The amount of accessibility you get for free from any of these is substantial. We recommend just adopting one.
If you must roll your own, the new HTML <dialog> element with showModal() handles focus trap and Escape natively now, and it's been in every browser since early 2022. It's not a perfect match for React's mental model — you call an imperative method to open it — but it's a lot better than a div with position: fixed.
5. State by color alone
The last one is sneaky because it doesn't break anything functional. It just quietly excludes a percentage of users.
Tutorials love the green dot / red dot pattern for status. Online: green. Offline: red. Build passing: green. Build failing: red. It's compact, it's pretty, and for the roughly 1 in 12 men with some form of color vision deficiency, it conveys nothing. WCAG calls this 1.4.1 Use of Color and our color contrast guide gets into the broader picture.
The fix isn't to remove the color. The fix is to add a second channel. Text, an icon shape, both — anything that's distinguishable without color.
// before
<span className={isOnline ? "bg-green-500" : "bg-red-500"} />
// after
<span className={isOnline ? "bg-green-500" : "bg-red-500"} aria-hidden="true" />
<span className="sr-only">{isOnline ? "Online" : "Offline"}</span>
<span>{isOnline ? "Online" : "Offline"}</span>
Yes, that's a lot more code than a colored dot. That's the whole point. The colored dot is an artifact of a tutorial trying to fit the example in three lines. The real version has more lines because it's doing more work — work that the tutorial's author either took as obvious or didn't have room to show.
What's actually going on here
If you re-read those five patterns, they have something in common. None of them are things React tutorial authors don't know. They're things that don't fit. A React tutorial has 12 minutes to teach hooks, JSX, state, and a working example. The accessible version of every component is between 30% and 200% more code. Something has to give, and the thing that gives is the part that doesn't have a visible failure on the screen recording.
That isn't a moral failing. It's a compression problem. The fix isn't to make tutorials longer — they'd lose readers. The fix is for the people downstream (us, you, anyone shipping a React app to the public) to know which corners got cut and patch them on the way out. The five patterns above account for somewhere around 60% of the violations we find on a typical React audit. Fix those five and you've meaningfully improved the experience for keyboard users and screen reader users on your site, with maybe an hour of work per component library.
The other thing worth saying: none of this requires a deep ARIA rabbit hole. Four of the five fixes above are vanilla HTML — <button>, <label htmlFor>, native <dialog>, visible text. ARIA is the escape hatch when HTML can't express what you mean. For most React tutorial residue, HTML is enough.
We're still finding new variants of each of these every week, so if you've got one we missed, drop it in the comments. We collect them.
Top comments (0)