Accessibility in React: Common Mistakes and How to Fix Them

Accessibility in React: Common Mistakes and How to Fix Them

A look at the most common accessibility mistakes in React applications: missing semantics, broken focus management, unlabeled elements, and silent dynamic updates. Covers what to fix, when to use component libraries, and how to audit your app.

Aurora Scharff

Aurora Scharff

April 23, 2026

Accessibility (often shortened to a11y) means building applications that work for people who interact with them in different ways: navigating with a keyboard, using a screen reader that reads the page aloud, zooming the browser to 200%, or disabling animations. Web accessibility is guided by the Web Content Accessibility Guidelines (WCAG), which define the standards that websites and applications should meet. 96% of websites have detectable WCAG violations, so there's a lot of room to improve.

This post focuses on the interactive patterns that commonly go wrong in React component code: missing semantics, broken focus, unlabeled elements, and silent dynamic updates. These are the mistakes that show up in almost every React codebase, and most of them are quick to fix once you know what to look for.

Use the Right HTML Element

The single biggest accessibility improvement you can make is choosing the right element. A <button> is focusable, responds to Enter and Space, and announces itself to screen readers. A <div onClick> does none of that.

      // This looks like a button, but it's not one
<div className="btn" onClick={handleClick}>
  Submit
</div>

// This is a button
<button onClick={handleClick}>
  Submit
</button>

    

The <div> version requires adding role="button", tabIndex={0}, an onKeyDown handler for Enter and Space, and focus styles. That's reimplementing what the browser gives you for free with <button>.

The same applies to links. Use <a href> for navigation, <button> for actions. Use <nav> for navigation groups, <main> for the primary content area, <ul> for lists. Screen readers use these elements to let users jump between sections, understand page structure, and navigate efficiently.

Document Structure and Heading Hierarchy

Screen reader users rely heavily on headings to navigate a page. A screen reader can list all the headings on a page and jump between them, so the heading hierarchy acts as a table of contents for your application.

Headings should follow a logical order: <h1> for the page title, <h2> for major sections, <h3> for subsections, and so on. Don't skip levels (going from <h1> to <h3>) and don't pick heading levels based on font size. Use CSS for styling and HTML for structure.

      // Incorrect: heading levels chosen for visual size
<h1>My App</h1>
<h4>Welcome back, Jane</h4>
<h2>Recent Orders</h2>
<h4>Order #1234</h4>

// Correct: logical heading hierarchy
<h1>My App</h1>
<h2>Welcome back, Jane</h2>
<h2>Recent Orders</h2>
<h3>Order #1234</h3>

    

Landmark elements like <header>, <nav>, <main>, <aside>, and <footer> serve a similar purpose at the page level. Screen readers can list all landmarks and jump to them directly, so wrapping your layout in the right landmark elements gives users a way to skip to the part of the page they need.

In React, the rules are the same as in HTML. JSX doesn't change what's semantic, it just changes the syntax (htmlFor instead of for, className instead of class).

Label Everything Interactive

Every interactive element needs an accessible name. For form inputs, that's a <label>:

      <label htmlFor="email-input">Email address</label>
<input id="email-input" type="email" />

    

Placeholder text is not a label. It disappears when the user starts typing.

For buttons with only an icon, the icon is decorative and should be hidden from screen readers with aria-hidden="true". Without it, some screen readers will try to announce the SVG, which creates noise. The accessible name goes on the button itself, either with aria-label or visually hidden text:

      // Using aria-label on the button
<button aria-label="Close dialog" onClick={onClose}>
  <XIcon aria-hidden="true" />
</button>

// Using visually hidden text (e.g. Tailwind's sr-only)
<button onClick={onClose}>
  <XIcon aria-hidden="true" />
  <span className="sr-only">Close dialog</span>
</button>

    

The screen reader announces the button's action ("Close dialog"), not the icon graphic. If your icon component doesn't support aria-hidden as a prop, you can wrap it in a <span aria-hidden="true"> instead.

The same problem shows up with images and links. Every <img> needs an alt attribute. If the image conveys information, the alt text should describe it. If it's purely decorative, use an empty string so screen readers skip it:

      // Informative image
<img src="/product.jpg" alt="Red running shoes, side view" />

// Decorative image
<img src="/divider.svg" alt="" />

    

An image with no alt attribute at all is different from alt="". Without the attribute, some screen readers will read the file name, which is almost never useful. Missing alt text appears on over half of all websites according to the WebAIM Million analysis.

If a link or button contains only an image or icon with no visible text, it shows up as an empty link to screen readers:

      // Empty link: screen reader announces just "link"
<a href="/profile">
  <UserIcon aria-hidden="true" />
</a>

// Fixed with aria-label
<a href="/profile" aria-label="Your profile">
  <UserIcon aria-hidden="true" />
</a>

// Or with visually hidden text
<a href="/profile">
  <UserIcon aria-hidden="true" />
  <span className="sr-only">Your profile</span>
</a>

    

When a label already exists somewhere else in the DOM, point to it with aria-labelledby:

      <h2 id="cart-heading">Shopping Cart</h2>
<ul aria-labelledby="cart-heading">
  {items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>

    

Connect Labels and Errors With useId

For IDs that connect labels to inputs, use useId instead of hardcoding strings. It generates stable, unique IDs that work correctly with server rendering and multiple instances of the same component.

The same pattern extends to form errors. Showing a red error message next to an input is not enough on its own. If the message isn't programmatically associated with the input, screen reader users won't hear it when they return to fix the field. Use aria-describedby to connect the error to its input, and aria-invalid to mark the field:

      function TextField({ label, error }) {
  const id = useId();
  const errorId = `${id}-error`;

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
      />
      <p id={errorId} role="alert">
        {error || ''}
      </p>
    </div>
  );
}

    

When the user tabs back to the input, their screen reader announces the label, the invalid state, and the error message together because aria-describedby links them. Without that link, the error is just a paragraph floating near the input with no programmatic connection.

Note that the role="alert" container stays in the DOM even when there's no error. Screen readers monitor existing live regions for content changes, so if you conditionally render the element itself ({error && <p role="alert">...}), many screen readers won't announce it when it first appears. Keep the container mounted and update its content instead.

Manage Focus

In a traditional website, navigating to a new page resets focus to the top. In a React SPA, route changes swap content without a full page load, so focus stays wherever it was. That can mean a keyboard or screen reader user is focused on an element that no longer exists.

Move focus to the new content when the page changes significantly:

      function PageContent({ title, children }) {
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    headingRef.current?.focus();
  }, [title]);

  return (
    <main>
      <h1 ref={headingRef} tabIndex={-1}>{title}</h1>
      {children}
    </main>
  );
}

    

tabIndex={-1} makes the heading programmatically focusable without adding it to the tab order.

The same principle applies to modals. When a modal opens, focus should move into it. When it closes, focus should return to the element that triggered it. The native <dialog> element handles most of this automatically, including focus trapping and closing on Escape:

      function Modal({ isOpen, onClose, title, children }) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const titleId = useId();

  useEffect(() => {
    if (isOpen) {
      dialogRef.current?.showModal();
    } else {
      dialogRef.current?.close();
    }
  }, [isOpen]);

  return (
    <dialog ref={dialogRef} onClose={onClose} aria-labelledby={titleId}>
      <h2 id={titleId}>{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </dialog>
  );
}

    

showModal() traps focus inside the dialog, adds a backdrop, and handles the Escape key. The onClose event fires when the dialog is dismissed by any method, so your React state stays in sync.

Focus also matters when elements are removed from the page. React makes it easy to conditionally render content, but if a user is focused on an element and it gets removed, focus can jump to the <body>, losing the user's place entirely:

      function TodoList({ items, onDelete }) {
  const listRef = useRef<HTMLUListElement>(null);

  function handleDelete(id) {
    onDelete(id);
    listRef.current?.focus();
  }

  return (
    <ul ref={listRef} tabIndex={-1} aria-label="Todo items">
      {items.map(item => (
        <li key={item.id}>
          {item.text}
          <button aria-label={`Delete ${item.text}`} onClick={() => handleDelete(item.id)}>
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

    

Without the focus management in handleDelete, deleting an item would leave focus on a now-removed DOM node. Moving focus to the list (or the next item) keeps the user oriented. The same applies to collapsible sections, tab panels, or any UI where content appears and disappears.

For more complex overlays, like nested modals, popovers with specific positioning, or menus that need arrow key navigation, building it yourself gets hard fast. A custom select alone needs keyboard navigation with arrow keys, escape to close, focus trapping, aria-expanded, aria-activedescendant, and more. A broken implementation can be worse than a missing one. This is where accessible component libraries like Ariakit, React Aria, Radix, and Base UI come in. They provide unstyled, accessible primitives that handle all of this for you: correct ARIA roles and attributes, keyboard interactions, focus management, and screen reader support. You bring the styling, they bring the behavior.

Announce Dynamic Updates

React applications frequently update parts of the page without a full reload: toast notifications, form validation errors, status messages. Sighted users see these changes, but screen reader users won't know about them unless the browser is told to announce them.

Use role="alert" for errors and role="status" for non-urgent updates. As with form errors, keep the container in the DOM and update its content rather than conditionally rendering it:

      <div role="alert">{errorMessage}</div>
<div role="status">{statusMessage}</div>

    

role="alert" interrupts the screen reader immediately. role="status" waits for it to finish what it's currently saying. Use alert sparingly, only for errors or critical information.

Style With ARIA and Data Attributes

One practical benefit of using proper ARIA attributes is that you can style based on them. Instead of managing separate state variables for visual styling, your styles can reflect the actual accessible state of the component. For example, in CSS or Tailwind:

      // Instead of tracking "isActive" state for styling
<li className={isActive ? 'bg-blue-100' : ''}>...</li>

// Style based on the ARIA attribute that's already there
<li className="aria-selected:bg-blue-100">...</li>

// Or use data attributes provided by component libraries
<li className="data-[active-item]:bg-blue-100">...</li>

    

This approach keeps your styles aligned with accessible state. If an element is aria-disabled, aria-expanded, or aria-selected, you can style it directly with CSS attribute selectors or Tailwind's modifier syntax. It also means you can differentiate between mouse and keyboard focus using data-focus-visible rather than applying the same focus styles for both interaction types.

This is one of the practical benefits of using accessible component libraries. They expose the right ARIA attributes and data attributes on every element, so you can style with Tailwind or CSS without managing any of the underlying state or behavior yourself.

Quick Audit: What to Check in Your React App

If you want to improve accessibility in an existing application, here's where to start:

  1. Find every <div> or <span> with an onClick. Replace them with <button> or <a href> as appropriate.
  2. Check your heading hierarchy. Do headings follow a logical order (h1 > h2 > h3)? Are you using heading levels for styling instead of structure?
  3. Check every <input>, <select>, and <textarea>. Does each one have a <label> with a matching htmlFor? Placeholder text doesn't count.
  4. Check every icon button and icon link. Does it have an aria-label or visually hidden text? Is the icon itself hidden from screen readers with aria-hidden="true"?
  5. Check every <img>. Does it have an alt attribute? Is the alt text descriptive for informative images and empty (alt="") for decorative ones?
  6. Check your form errors. Is each error message connected to its input with aria-describedby? Is the input marked with aria-invalid?
  7. Check your modals and dialogs. Does focus move into them when they open? Does it return to the trigger when they close? Can you close them with Escape?
  8. Look for conditional rendering. When an element is removed from the DOM, does focus move somewhere useful, or does it get lost?
  9. Look for dynamic updates. Toast notifications, status messages: are they using role="alert" or role="status" so screen readers announce them?
  10. Tab through the entire application. Can you reach and operate every interactive element? Can you always see where focus is?
  11. Run automated checks. Tools like axe-core catch missing labels, invalid ARIA, and contrast issues automatically.

You don't need to fix everything at once. Start with the forms and the most-used interactive components, and work outward from there. Most of these issues are quick to fix once you know what to look for, and each one makes a real difference for someone trying to use your application.


Sources:

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Looking for Certified Developers?

We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.

Let us help you get the resources you need.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)