Seven WCAG violations hiding in your React components right now
Most React apps ship with the same accessibility bugs. Here are the seven WCAG 2.2 violations we see constantly in JSX codebases, with concrete code fixes for each one.
Open your browser's DevTools on any mid-sized React app. Run axe. Count the violations. If you have never done this before, the number is going to be higher than you expect — not because your team is careless, but because JSX makes it remarkably easy to write inaccessible markup without realizing it.
React gives you powerful abstractions. It also gives you <div onClick> and lets you ship it without complaint. The compiler does not care that a screen reader cannot interact with that element. ESLint might catch it if you have eslint-plugin-jsx-a11y configured, but most teams either skip that plugin or ignore its warnings until the backlog is unmanageable.
We built inklu to scan codebases against WCAG 2.2 and generate pull requests with fixes. After processing thousands of JSX and TSX files, the same violations keep surfacing. Here are the seven we see most often, why they matter, and exactly how to fix them.
1. Clickable divs instead of buttons
This is the most common accessibility bug in React codebases, full stop.
// Broken — invisible to keyboard users and screen readers
<div className="btn-primary" onClick={handleSubmit}>
Submit
</div>
A <div> has no semantic role. It is not focusable. It does not respond to Enter or Space. A keyboard user cannot reach it. A screen reader does not announce it as interactive. Adding role="button" and tabIndex={0} and onKeyDown is a patch — it works, but you are reimplementing what <button> gives you for free.
// Fixed — semantic, focusable, keyboard-accessible by default
<button className="btn-primary" onClick={handleSubmit}>
Submit
</button>
The same applies to links. If clicking an element navigates the user somewhere, it should be an <a> with an href. If it triggers an action, it should be a <button>. This is not a style preference — it is how assistive technology determines what an element does.
2. Images without meaningful alt text
React does not require an alt prop on <img>. You can write <img src={photo} /> and it compiles fine. Every instance is a WCAG 1.1.1 violation.
The fix depends on the image's purpose:
// Informative image — describe what it shows
<img src={chartUrl} alt="Monthly revenue chart showing 22% growth in Q1" />
// Decorative image — empty alt, not missing alt
<img src={dividerGraphic} alt="" />
// Image inside a link — alt describes the link destination
<a href="/profile">
<img src={avatarUrl} alt="Your profile" />
</a>
The distinction between alt="" (decorative, skip this) and no alt attribute at all (broken, screen reader reads the filename) is critical. An empty string is intentional. A missing attribute is a bug.
Watch for dynamically rendered images too. If your app displays user-uploaded content, you need a mechanism to either require alt text at upload time or mark the image as decorative. Every unhandled <img> in a .map() loop is a violation multiplied by however many items render.
3. Form inputs without programmatic labels
Placeholder text is not a label. It disappears when the user starts typing, which means it disappears exactly when the user needs to remember what the field is for. Screen readers may or may not announce placeholder text depending on the browser and AT combination.
// Broken — no programmatic association
<input type="email" placeholder="Email address" />
// Fixed — explicit label association
<label htmlFor="email">Email address</label>
<input id="email" type="email" placeholder="e.g. you@company.com" />
If your design does not show a visible label (floating labels, icon-only fields), use aria-label or aria-labelledby:
// Visually hidden label for search fields
<label htmlFor="search" className="sr-only">Search</label>
<input id="search" type="search" placeholder="Search..." />
Every <input>, <select>, and <textarea> needs either a <label> with a matching htmlFor/id pair, or an aria-label, or an aria-labelledby pointing to a visible element. No exceptions. This is WCAG 1.3.1 and 4.1.2.
4. Missing document language
This one takes five seconds to fix and teams still miss it. If your <html> tag does not have a lang attribute, screen readers cannot select the correct pronunciation engine. A French screen reader will try to read English text with French phonetics. The result is incomprehensible.
In Next.js, you set this in next.config.js or your root layout. In Create React App or Vite, it is in index.html. In a custom setup, find wherever your <html> tag is rendered.
<!-- Fix: add lang to your html element -->
<html lang="en">
If your app supports multiple languages, update the lang attribute dynamically based on the user's locale. This is one of the few WCAG checks that has zero visual impact and zero risk of breaking anything — there is no reason not to fix it today.
5. Color contrast failures
WCAG 2.2 requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18px+ or 14px+ bold). It also requires 3:1 for UI components and graphical objects (this is the 1.4.11 "Non-text Contrast" criterion that WCAG 2.1 added and teams still overlook).
The violations we see most often in React codebases:
Light gray placeholder text. Browsers default to a low-contrast gray for placeholders. If your design system does not override this, every placeholder fails.
Disabled state styling. Grayed-out buttons and inputs often drop below 3:1. WCAG does not require contrast on truly disabled controls, but if users need to read the label to understand what is disabled and why, low contrast is a usability failure even if technically exempt.
Text on images or gradients. A heading over a hero image might pass contrast in one spot and fail in another. If you cannot guarantee contrast across the entire text bounding box, add a semi-transparent overlay or a solid background behind the text.
Contrast is math. Tools like the WebAIM Contrast Checker or the Chrome DevTools contrast picker give you the ratio instantly. inklu flags every contrast failure in your codebase and can generate fixes that adjust color values to meet the threshold — check our comparison with axe DevTools to see how automated fix generation differs from manual flagging.
6. Focus management in client-side navigation
Single-page apps break the browser's built-in focus management. When a user clicks a link in a traditional website, the browser loads a new page and focus moves to the top of the document. In a React app with client-side routing, the URL changes but focus stays wherever it was — often on the link the user just clicked, which may no longer be visible.
For keyboard and screen reader users, this is disorienting. They activated a navigation link, the content changed, but their focus did not move. They have no indication that anything happened.
The fix depends on your router, but the pattern is the same: after a route change, move focus to a predictable location.
// Example with React Router — move focus after navigation
import { useLocation } from 'react-router-dom';
import { useEffect, useRef } from 'react';
function App() {
const location = useLocation();
const mainRef = useRef(null);
useEffect(() => {
// Move focus to main content area on route change
if (mainRef.current) {
mainRef.current.focus();
}
}, [location.pathname]);
return (
<main ref={mainRef} tabIndex={-1}>
{/* Route content */}
</main>
);
}
The tabIndex={-1} makes the element programmatically focusable without adding it to the tab order. This is a common pattern for containers that should receive focus via JavaScript but not via Tab key.
Also: announce route changes to screen readers. Libraries like @reach/router handle this automatically. React Router does not. You may need a live region that announces the new page title.
7. ARIA misuse that makes things worse
ARIA (Accessible Rich Internet Applications) is powerful. It is also dangerous in the wrong hands. The first rule of ARIA is literally "don't use ARIA" — if a native HTML element does what you need, use that instead.
The violations we see:
Redundant roles. <button role="button"> does nothing useful. <a href="..." role="link"> is redundant. These are noise, not bugs, but they signal that the team is guessing at ARIA rather than understanding it.
Conflicting roles. <a href="/dashboard" role="button"> tells the screen reader this is a button, but the browser treats it as a link (Enter activates it, not Space). The user gets conflicting signals.
aria-hidden="true" on focusable elements. This creates a paradox: the element is hidden from the accessibility tree but still reachable via keyboard. The screen reader user tabs to an element that, from the AT's perspective, does not exist. This is a WCAG 4.1.2 failure.
// Broken — focusable but hidden from accessibility tree
<button aria-hidden="true" onClick={handleClose}>
Close
</button>
// Fixed — if it should be hidden, remove it from tab order too
// Or better: just remove aria-hidden if the button should be usable
<button onClick={handleClose} aria-label="Close dialog">
<CloseIcon />
</button>
Missing accessible names on icon buttons. A <button> containing only an SVG icon has no accessible name. Screen readers announce it as "button" with no further context. Add aria-label to the button.
The pattern to follow: use semantic HTML first. Reach for ARIA only when HTML cannot express the interaction (tabs, comboboxes, tree views, dialogs). When you do use ARIA, follow the WAI-ARIA Authoring Practices — they specify exactly which roles, states, and keyboard interactions each widget pattern requires.
Catching these before they ship
Each of these seven violations is fixable with a targeted code change. None of them require a redesign. The challenge is not difficulty — it is visibility. Teams do not fix what they cannot see, and most codebases have never been scanned.
inklu scans your JSX and TSX files against WCAG 2.2 using axe-core plus over 50 proprietary rules, then generates pull requests with AI-powered fixes. The workflow fits into how your team already works: a PR appears, you review the diff, you merge or close. No context switching, no separate remediation tracker, no six-month project.
If you are running GitHub Actions, you can integrate inklu into your CI pipeline so new violations are caught on every push — before they reach production.
The seven violations in this post account for the vast majority of automated findings on React apps. Fix them, and your WCAG conformance improves dramatically. Prevent them from recurring with CI integration, and accessibility becomes a property of your codebase rather than a periodic audit.
See what inklu finds in your codebase. Book a demo at inklu.io or email hello@inklu.io.