October 30, 2026·8 min read
React and Next.js Accessibility Fixes in Code
React and Next.js accessibility problems trace to four habits: unnamed controls, lost focus, missing labels, and custom components that skip native behavior.
React and Next.js do not make a site inaccessible on their own, and they do not make it accessible on their own either. The framework renders whatever components you write. When those components skip native HTML behavior, hide names, or lose focus on navigation, the result is a page that looks finished and behaves badly for keyboard and screen reader users. Most React accessibility fixes come down to four code habits, and each one has a concrete fix.
The rule behind all four is WCAG 4.1.2 Name, Role, Value, at Level A: the name and role of every user interface component must be programmatically determinable. React components that render a div where a button belongs, or that pass a name only as a visual prop, break that rule in code that looks clean in the editor.
Key takeaways
- Use native elements first. A button element already has a role, keyboard activation, and a name from its text.
- Return focus deliberately after dialogs close and routes change. Do not leave focus where the old content was.
- Pair every label with its control using htmlFor and id in React, and keep the id stable across renders.
- Hide decorative icons with aria-hidden, and never hide a focusable element itself.
- Validate ARIA values in code review, since a typo in a prop is invisible in the browser.
Fix one: use native elements before custom components
The most common React accessibility bug is a clickable div. A div with an onClick handler looks like a button, but it has no button role, no keyboard activation, and no name in the accessibility tree unless ARIA is added by hand. Adding role, tabIndex, and key handlers by hand recreates what the button element already does, with more room for error.
Failing version:
{/* Failing: a div acting as a button, with no role or keyboard support */}
<div onClick={handleSave}>Save</div>
Passing version:
{/* Passing: a native button, with role and keyboard behavior built in */}
<button onClick={handleSave}>Save</button>
The same rule applies to links, checkboxes, radio buttons, and select menus. A native anchor with an href is a link. A native input with type checkbox is a checkbox with a checked state. A custom component that truly needs behavior no native element offers still needs the full pattern: role, name, keyboard handling, and state attributes.
Fix two: manage focus on dialogs and route changes
Single page apps move content without reloading the page, and focus does not follow automatically. Two moments matter most: dialogs and route changes.
When a dialog opens, move focus into it, usually to the dialog itself or its first focusable element, and keep Tab inside the dialog until it closes. When the dialog closes, return focus to the element that opened it. A user who opened a dialog from a "Settings" button and lands back at the top of the page after closing it has lost their place. Returning focus is a few lines of code with a ref: store the previously focused element on open, restore it on close.
Route changes in Next.js need the same attention. Client side navigation swaps page content without a full reload, so a screen reader user may not be told a new page has loaded, and keyboard focus may stay on the navigation link that triggered the change. The fix is to move focus to a sensible place on the new page, such as the main heading, and to announce the change. A common pattern is a visually hidden live region that announces the new page title, plus focus management that puts the user at the start of the new content.
Keep tab order in DOM order and avoid positive tabIndex values. Use tabIndex 0 only for a non-interactive element that genuinely needs focus, such as a dialog container, and tabIndex -1 for elements that should receive programmatic focus but stay out of the Tab order.
Fix three: labels that survive rendering
React uses htmlFor instead of for, because for is a reserved word in JavaScript. The pairing still works the same way: the label's htmlFor value must match the control's id value, exactly. The failure mode in React is unstable ids. An id generated fresh on every render, or duplicated across repeated components such as list items, breaks the pairing or points the label at the wrong control.
Failing version:
{/* Failing: the label is not paired with the input */}
<label>Email address</label>
<input type="email" />
Passing version:
{/* Passing: htmlFor matches a stable id */}
<label htmlFor="contact-email">Email address</label>
<input id="contact-email" type="email" />
For repeated components, derive the id from stable data, such as a product id or a field name, rather than from render order or a random value that changes. For error messages, connect them with aria-describedby pointing at the error element's id, and move focus to the first field with an error on submit where that fits the form's design. A label that is only visible as placeholder text is not a label: placeholder text vanishes when the user types, and it is not announced as a name in the same way.
Fix four: names, icons, and ARIA values in props
React passes ARIA attributes through to the DOM, which makes typos easy and invisible. A prop written as aria-lable sets an unknown attribute and no name. A prop written as aria-checked="no" sets a value the specification does not allow. Deque's axe rule for valid attribute values catches exactly this class of error, but code review should catch it first: read ARIA props as carefully as you read event handlers.
Decorative icons inside buttons and links need aria-hidden="true" on the icon itself, so the icon character stays out of the accessible name. MDN warns that aria-hidden must not be used on focusable elements, so put it on the inner icon span, never on the button or link. The button's name then comes from its visible text or its aria-label, cleanly.
{/* Passing: the icon is hidden, the text carries the name */}
<button aria-label="Search the site">
<span className="icon-search" aria-hidden="true" />
<span>Search</span>
</button>
A review checklist for React pull requests
Before merging a React or Next.js change, check these five items in the diff:
- Every interactive element is a native button, link, input, or select, or a custom component with a full role, name, and keyboard pattern.
- Every form control has a label paired by htmlFor and a stable id, and every error message is connected by aria-describedby.
- Dialogs move focus in on open and return it on close. Route changes move focus to the new content and announce the new page.
- Icons inside controls carry aria-hidden, and no focusable element carries aria-hidden itself.
- ARIA attribute names and values are spelled correctly and allowed for the role they sit on.
Run the automated scan after merging, then retest with a keyboard and a screen reader on the changed pages. The scan confirms validity. The manual retest confirms the names and focus behavior make sense in use.
How Axeazy helps with this exact issue
React and Next.js sites with a GitHub repository receive fixes as a real pull request with a visible code diff for review and merge. That route fits this article's fixes exactly, because each one is a code change a reviewer can read: a native element swap, a focus return, a label pairing, or an icon hiding. Read how the scan works for the method, and the capabilities page for the honest scope.
For the underlying rules, see the link names guide, the invalid ARIA attributes guide, and the aria-hidden guide in this series, plus the manual accessibility check for the retest routine after a merge.
Want a quick read on your own site? Run Axeazy's free 60 second scan . It needs no signup.
Last checked: October 30, 2026.
FAQ
Is a div with onClick ever acceptable as a button?
Only if you add the full button pattern yourself: role, keyboard activation with Enter and Space, focus handling, and a name. In practice, a native button element already provides all of that, so use it unless a custom pattern is genuinely required.
How do I handle focus after a Next.js route change?
Move focus to a sensible place on the new page, such as the main heading, and announce the new page title. Client side navigation does not move focus or announce the change on its own, so the code must do both deliberately.
Why do my React labels break in lists?
Because ids collide or change across renders. Derive each id from stable data, such as a field name or item id, and keep the label's htmlFor matched to it exactly. Do not generate a fresh id on every render.
Can Axeazy fix React accessibility issues automatically?
For sites with a GitHub repository, fixes arrive as a reviewable pull request with a visible diff. You review the code change, including names and alt text wording, before merging. Nothing is applied without review.
Sources
- W3C Understanding Success Criterion 4.1.2: Name, Role, Value, checked October 30, 2026
- W3C Understanding Success Criterion 2.4.4: Link Purpose (In Context), checked October 30, 2026
- MDN: aria-hidden attribute, checked October 30, 2026
- Deque axe rule: ARIA attributes must conform to valid values, checked October 30, 2026
- Axeazy methodology, checked October 30, 2026
Check your own site
See what is actually wrong with your site.
Free, 60 second WCAG 2.2 AA scan. No credit card required.
Scope of every report we ship: SCOPE LIMITATION: This report documents violations identified by automated WCAG 2.2 AA scanning using axe-core v4.11.0. Automated tools cannot test every WCAG criterion; many criteria require manual review. This report does not constitute legal advice, guarantee ADA compliance, or protect against legal action. ADA compliance is a legal determination made by courts. For full WCAG conformance assessment, engage an IAAP-certified accessibility professional.