← ALL WRITING

Accessible React Components: Focus, Contrast and Keyboard Order

Most accessibility failures I've found in production React apps are not exotic. They are the same four problems, they are all fixable at the component level, and automated tooling catches roughly a third of them.

Suraj Vaidya··9 min read

What makes a component accessible?

A component is accessible when it can be perceived, operated and understood without sight, without a mouse, and without assumptions about reaction time — which in practice means correct semantics, a visible and logical focus path, sufficient colour contrast, and state changes that are announced rather than only shown.

That definition is deliberately mechanical, because accessibility discussed as a principle tends to stay a principle. Discussed as four checkable properties of a component, it gets built.

Start with semantic HTML, not ARIA

The first rule of ARIA is not to use ARIA. A `<button>` is focusable, keyboard-operable, announced correctly and styled by every user agent's accessibility layer without a single attribute. A `<div role="button" tabIndex={0}>` needs a keydown handler for Enter and Space, an explicit focus style, and a disabled state you have to implement yourself — and it will still behave differently in at least one screen reader.

In React codebases the div-as-button pattern usually appears for styling reasons that no longer apply. Modern CSS resets a native button completely in three lines, so the cost of doing it properly is close to zero.

The same goes for `<nav>`, `<main>`, `<ul>` for lists of things, and real heading levels in order. Landmark and heading structure is how screen reader users skim a page, and a page built entirely from divs offers nothing to skim.

Focus: the thing most teams get wrong

Two failures dominate. The first is removing the focus outline for aesthetic reasons and not replacing it, which makes keyboard navigation invisible. The fix is `:focus-visible` — it shows the indicator for keyboard users and suppresses it for mouse clicks, which is what people actually want when they reach for `outline: none`.

The second is losing focus on navigation. In a single-page React app, changing route leaves focus wherever it was, so a screen reader user activates a link and is told nothing has happened. The fix is to move focus to the new page's heading, or to a skip target, on every route change, and to announce the new page title.

The third, less common but more damaging: focus that lands somewhere invisible. A modal that renders before focus moves into it, or a dropdown that keeps focus on a closed trigger, strands the user with no indication of where they are.

Keyboard order and the modal problem

Tab order should follow the visual reading order. It usually does, until someone reorders elements visually with CSS — `order`, `row-reverse`, absolute positioning — without reordering them in the DOM. The visual sequence and the tab sequence then disagree, and the disagreement is invisible to everyone testing with a mouse.

Modals need three specific behaviours, and partial implementations are common: focus moves into the dialog when it opens, focus is trapped inside it while open, and focus returns to the element that triggered it when it closes. Missing the third is the most frequent — the modal closes and focus resets to the top of the document, so a keyboard user has to tab back through the entire page.

This is a strong argument for taking dialogs, menus and comboboxes from a headless library that has already solved the behaviour, and applying your own tokens for the styling. These patterns are far more intricate than they look, and re-implementing them per project is how the gaps get in.

Contrast is a token problem, not a design review problem

Colour contrast fails get caught late because they are checked per screen, in review, by a person. They should be impossible to introduce, which means checking them at the token layer instead.

If the semantic tokens are defined as pairs — a surface colour and the text colour intended to sit on it — then every pair can be tested once, automatically, against the WCAG thresholds: 4.5:1 for body text, 3:1 for large text and for the non-text parts of interface components like input borders and focus indicators. A failing pair becomes a build error rather than a review comment.

Two contrast failures escape almost every process. Placeholder text, which is routinely set at a grey that fails, and disabled controls, which are widely assumed to be exempt and are not — a disabled control still needs to be readable enough that a user can tell what it is.

Announcing change without creating noise

When something changes on screen without a page load — a filter applied, a form saved, an error appearing — sighted users notice and screen reader users are told nothing unless you arrange it. That is what live regions are for: `aria-live="polite"` for status messages that can wait for a pause, `aria-live="assertive"` only for genuine interruptions.

The common mistake is overuse. Every toast, badge count and loading state wired into an assertive live region produces a constant interruption that makes the interface less usable rather than more. Be deliberate: announce the things a user needs to know to continue, and let the rest be discovered.

One React-specific trap: a live region must exist in the DOM before the message is inserted into it. A region that mounts at the same moment as its content will frequently not be announced at all.

Forms: labels, errors and the pattern that works

Every input needs a real `<label>` associated by `htmlFor`. Placeholder text is not a label — it disappears on focus, it usually fails contrast, and it is not reliably announced.

For errors, the pattern that works across screen readers is consistent: mark the field invalid with `aria-invalid`, point at the error message with `aria-describedby`, put the message text next to the field rather than only in a summary at the top, and never communicate the error state through colour alone.

On submit, move focus to the first invalid field, or to an error summary that links to each one. A form that reports failure without telling a keyboard user where the failure is has technically succeeded and practically not.

Images, icons and the alternative text nobody writes

Alternative text has a rule that is simpler than its reputation: describe the function the image performs, not its appearance. An icon inside a button that saves a document is not a floppy disk; it is “Save”. A decorative background flourish needs `alt=""` — an empty string, deliberately — so screen readers skip it, rather than no attribute at all, which causes some readers to announce the filename.

Icon-only buttons are the most common failure in modern React interfaces, because a well-designed icon feels self-explanatory to the person who chose it. Every one needs an accessible name, either through visually hidden text inside the button or `aria-label` on it, and the name should match whatever a user would say out loud to describe the action.

Inline SVG needs slightly more care than an `<img>`: it needs `role="img"` and a `<title>` when meaningful, or `aria-hidden="true"` when purely decorative and paired with adjacent text. Left unmarked, an SVG's internal element structure sometimes leaks into the accessibility tree as noise.

Tables and data-heavy interfaces

Data tables are where React applications most often fall back to divs, usually because a `<table>` is harder to make responsive. The cost is significant: a real table with `<th>` headers and a `scope` attribute lets a screen reader user hear the column name with each cell as they move across a row. A grid of divs gives them a stream of unlabelled values.

If the table must collapse on small screens, change the presentation with CSS rather than changing the markup. Rebuilding the same data as cards on mobile and as a table on desktop means maintaining two accessibility implementations, and one of them will always lag behind.

Sortable columns need the sort state announced, not just indicated with an arrow — `aria-sort` on the header handles this. And pagination or filtering that replaces the table's contents should announce the new result count through a polite live region, or the user has no way to know anything happened.

What to test, and what tooling won't catch

Automated tooling — axe, the accessibility panel in Lighthouse, eslint-plugin-jsx-a11y — catches roughly a third of issues in practice. That third is worth automating, because it is the tedious third: missing alternative text, missing labels, contrast on static text, invalid ARIA attributes.

The other two thirds need a person, and mostly need very little time.

  • Unplug the mouse and complete the primary flow using only the keyboard. Most failures surface within two minutes.
  • Tab through the page and watch whether the focus indicator is always visible and always where you expect it.
  • Zoom the browser to 200% and check that nothing is clipped or trapped behind a fixed element.
  • Turn on VoiceOver on macOS or NVDA on Windows and listen to one form and one modal. This is uncomfortable at first and more informative than any report.
  • Run through the flow with animation disabled via `prefers-reduced-motion` and confirm nothing depends on the animation to make sense.

Why this belongs in the design system

Accessibility implemented per feature has to be re-argued every sprint and re-tested every release. Accessibility implemented once inside a shared component is inherited by everything built from it, and the argument only happens once.

That is the strongest practical case for a design system that I know of, and it is rarely the one made. If the button, the input, the modal and the token pairs are correct, then most of a product is correct by default — and the accessibility work that remains is the small amount that is genuinely specific to each feature.