Accessibility in React: The Quick Wins That Cover 80% of Issues

Technical PM + Software Engineer
Accessibility in React gets talked about in two unhelpful ways.
Sometimes it is treated like a specialist discipline that only experts should touch. Other times it gets reduced to a compliance checklist that people remember late, usually right before launch, after most of the expensive UI decisions are already baked in.
Neither framing is very useful if you are trying to ship better frontend work week after week.
The more practical truth is that most React apps do not fail accessibility because teams ignored some giant advanced standard. They fail because they keep missing the same handful of high-impact basics:
- using the wrong element for the job
- breaking keyboard flows
- hiding or losing focus
- shipping unlabeled controls
- relying on color alone
- forgetting that dynamic UI needs explicit accessibility behavior
The good news is that those are all fixable.
And the better news is that fixing them covers a surprising amount of the real-world damage.
Start with the simplest rule: use native HTML whenever you can
This one is still the biggest win.
If a control is a button, use a button. If it is a link, use an a. If it is a form field, use the corresponding form element.
A lot of accessibility pain comes from trying to restyle the platform out of existence and then rebuilding the lost behavior by hand.
That is how teams end up with things like:
divelements pretending to be buttons- clickable containers with no keyboard behavior
- links that are really actions
- custom toggles that look polished but behave inconsistently
Native elements come with a lot for free:
- keyboard support
- screen reader semantics
- focus behavior
- expected browser interactions
- fewer edge cases for you to own
In plain English: if the browser already knows how to do it accessibly, let the browser help you.
ARIA is not a substitute for semantics
This is where many React teams get into trouble.
They know accessibility matters, so they start sprinkling ARIA around as if it can repair a bad structural decision.
It usually cannot.
If you take a div, give it role="button", add tabIndex={0}, and wire up click handlers, you are signing up to recreate button behavior properly. That means keyboard support, state communication, focus expectations, and more.
Sometimes that is necessary. Most of the time it is avoidable.
ARIA is best when it adds missing information, not when it tries to impersonate a native element you could have used directly.
That means it is excellent for:
- labelling relationships
- describing state
- connecting help text and errors
- exposing dynamic announcements
It is much less good as a replacement for semantic markup.
Keyboard support is where many polished UIs quietly fall apart
A surprising number of React interfaces look finished and still become awkward or unusable the moment you stop using a mouse.
That is because keyboard support is often tested late, if it is tested at all.
The simplest baseline is this:
Can someone tab through the flow, understand where they are, activate the controls, and complete the task without getting trapped or lost?
That one question catches a lot.
If the answer is no, the feature is not really done.
Some common failure modes:
- custom controls only respond to click
- tab order feels random because the DOM order and visual order disagree
- overlays open without moving focus anywhere useful
- menus and drawers trap focus badly or not at all
- dismissing a modal drops focus somewhere unpredictable
A minimal accessible switch example shows the shape of the work:
import React, { forwardRef } from 'react';
type SwitchProps = {
checked: boolean;
onChange: (next: boolean) => void;
label: string;
};
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
function Switch({ checked, onChange, label }, ref) {
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
>
{checked ? 'On' : 'Off'}
</button>
);
}
);
Even there, notice the advantage of using a button as the interaction surface. You get much of the input behavior for free instead of faking everything from scratch.
Focus visibility is not optional polish
One of the fastest ways to make an interface harder to use is removing focus styles because they look messy.
This still happens all the time.
The fix is not to hide focus. The fix is to design focus well.
A visible focus style should:
- be easy to spot
- have enough contrast
- work across component states
- feel consistent across the interface
focus-visible is especially helpful because it lets you keep keyboard focus cues without making every mouse click look noisy.
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 3px;
border-radius: 4px;
}
The exact style can vary. The principle should not.
Users need to know where they are.
Dynamic React UI needs explicit focus management
This is where React apps create accessibility problems that static pages usually do not.
When the DOM changes in response to state, focus does not magically move in a helpful way. If you open a modal, inject an error summary, close a drawer, or render a menu portal, the accessibility behavior has to be intentional.
That usually means:
- focusing something sensible when an overlay opens
- trapping focus within a modal while it is active
- restoring focus to the triggering element when it closes
- moving focus to meaningful error context after failed submission
- using live regions carefully for important updates
This is one reason accessibility cannot be left to styling alone. In React, component behavior is often the issue.
A basic modal pattern looks like this:
import { useEffect, useRef } from 'react';
type ModalProps = {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
};
export function Modal({ isOpen, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
previousFocus.current = document.activeElement as HTMLElement | null;
requestAnimationFrame(() => {
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
});
return () => {
previousFocus.current?.focus();
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" ref={dialogRef}>
<button onClick={onClose}>Close</button>
{children}
</div>
);
}
You can absolutely use a library for this, and often you should. The point is that the behavior has to exist somewhere.
Labels do more work than people think
A lot of accessibility issues are really naming issues.
A screen full of unlabeled icon buttons, vague link text, or detached form fields creates friction fast, even for users who are not using assistive technology directly.
Good labels help everyone scan and recover more easily.
For forms, that usually means:
- visible labels whenever possible
- explicit connection between labels and inputs
- descriptive help text when needed
- error messages that are programmatically associated with the field
A basic input pattern looks like this:
type TextInputProps = {
id: string;
label: string;
error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;
export function TextInput({ id, label, error, ...props }: TextInputProps) {
const describedBy = error ? `${id}-error` : undefined;
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} aria-describedby={describedBy} {...props} />
{error ? (
<p id={`${id}-error`} role="alert">
{error}
</p>
) : null}
</div>
);
}
This is not fancy, which is part of why it works.
The user gets a label. The field gets context. The error is attached in a way assistive technology can understand.
Forms are one of the highest-leverage places to improve accessibility
If you only have time to improve one area, forms are usually a strong candidate.
Why?
Because forms are where people are trying to finish something.
And when form accessibility is weak, the pain is immediate:
- the field is not labeled properly
- the required state is unclear
- the error is visible but not announced
- focus stays at the submit button after failure
- validation depends too much on color alone
A better form flow usually includes:
- proper labels
- clear required and optional cues
- linked error text
- moving focus to the first problem after submit
- readable success and failure messaging
These are not exotic improvements. They are just the things that reduce confusion the fastest.
Color contrast problems usually show up in states, not just defaults
A lot of teams run a contrast check once on the default button color and then assume they are done.
That is not where many of the real issues live.
You need to look at:
- hover states
- focus states
- pressed states
- disabled states
- selected states
- error states
- text on tinted backgrounds
This matters because a component that passes contrast in its resting state can still become hard to read exactly when someone is trying to use it.
That is why contrast should be treated like a full state-system concern, not just a token-level checkbox.
React component APIs can either help or sabotage accessibility
A lot of accessibility regressions in React come from component abstraction itself.
Common examples:
- wrapper components that swallow
aria-*props - components that do not forward refs, making focus management harder
- design-system primitives that override semantics too aggressively
- custom abstractions that make the accessible path harder than the visually convenient one
A well-behaved component should usually:
- forward refs when interaction matters
- pass through standard HTML and ARIA props
- avoid imposing unnecessary roles
- let the underlying semantic element stay meaningful
That is especially important in shared UI systems, because one bad primitive can spread the same accessibility problem everywhere.
Tooling helps, but it does not replace human testing
Accessibility tools are useful. They are not enough on their own.
I absolutely want things like:
eslint-plugin-jsx-a11y- axe checks in components and pages
- CI scans for major regressions
Those catch a lot of issues early.
But they do not answer everything.
You still need some human testing, especially for critical flows.
The most valuable manual checks are often simple:
- tab through the experience
- complete the flow without a mouse
- confirm focus remains visible and logical
- test that form errors are understandable and recoverable
- sanity check with a screen reader on key journeys
You do not have to become a full-time accessibility specialist to do useful testing. You just need to stop treating automation as if it understands the entire user experience.
If you inherit a messy app, fix these things first
When an app has a lot of accessibility debt, prioritization matters.
The fastest high-value order is usually:
- keyboard blockers
- missing labels and broken form feedback
- invisible or lost focus
- modal and drawer focus behavior
- semantic structure improvements
- contrast and state polish
That order works because it focuses first on whether users can complete tasks at all.
Accessibility work gets a lot easier to justify when you frame it around flow completion and recovery, not just standards language.
Final takeaway
Most React accessibility issues are not caused by missing some advanced technique. They come from repeating a few avoidable mistakes across a lot of components.
That is also why the fixes are so worth doing.
If you consistently:
- choose native elements first
- keep keyboard behavior intact
- make focus visible and intentional
- label controls clearly
- build forms that recover well
- test dynamic UI like a real person would use it
you will eliminate a huge percentage of the issues that make interfaces frustrating or unusable.
That is the real opportunity here.
Accessibility is not a separate phase of frontend work. It is one of the clearest ways to tell whether the interface actually respects the people trying to use it.