Accessibility in React: The Post I Should Have Written Years Ago

By Odilon12 min read

Accessibility in React: The Post I Should Have Written Years Ago#

I should have written this post years ago. I should have cared about accessibility from my first React component. I didn't. That's on me.

For the first three years of my React career, "accessible" meant "no red badges in the axe browser extension." I'd run the checker, fix whatever it complained about, and ship. I told myself that was good enough. It wasn't.

Then last month I sat down with VoiceOver and tried to use a form I'd built. Actually use it — tab through the fields, listen to what gets announced, try to submit. It was unusable. Fields with no labels. A modal that trapped keyboard focus outside itself. Error messages that appeared visually but were never announced to the screen reader. Buttons that looked clickable but responded to nothing via keyboard.

It was humbling. And the worst part? Every single issue was fixable with patterns I could have learned in an afternoon.

Start With Boring HTML#

The most important accessibility advice I can give is the most boring: use the right HTML element.

A <button> is focusable by keyboard, activates on Enter and Space, has role="button" built in, and participates in the accessibility tree correctly. A <div onClick={...}> has none of these things. None.

tsx
// This is what I used to write. Don't be like past me.
<div
  className="action-button"
  onClick={handleDelete}
  style={{ cursor: "pointer" }}
>
  Delete
</div>

// This is correct
<button
  type="button"
  className="action-button"
  onClick={handleDelete}
>
  Delete
</button>

Same applies to navigation (<nav>), main content (<main>), page sections (<section> with an aria-label), and headings (<h1> through <h6> in logical order). Get these right and you get an enormous amount of accessibility for free. Semantic HTML was solving these problems before React existed. We just forgot.

Accessible Forms (or: How I Failed at Labels)#

Forms are where I've made the most accessibility mistakes. Two in particular:

Missing labels. Every input needs a visible label linked via htmlFor/id. Placeholder text is not a label — it disappears when the user starts typing. I know this now. I shipped probably a dozen forms without proper labels before someone told me.

tsx
// What I shipped for embarrassingly long
<input
  type="email"
  name="email"
  placeholder="Enter your email"
  className="input"
/>

// What it should have been from day one
<div className="field">
  <label htmlFor="email" className="label">
    Email address
  </label>
  <input
    id="email"
    type="email"
    name="email"
    placeholder="you@example.com"
    className="input"
    aria-describedby="email-hint"
  />
  <p id="email-hint" className="hint-text">
    We will send a confirmation to this address.
  </p>
</div>

Error messages not linked to inputs. When validation fails, the error message needs to be associated with its field so screen readers announce it. I used to just render a red <p> near the input and call it done. That's visual-only communication.

tsx
function PasswordField({ error }: { error?: string }) {
  const errorId = "password-error";

  return (
    <div className="field">
      <label htmlFor="password" className="label">
        Password
      </label>
      <input
        id="password"
        type="password"
        name="password"
        className={`input ${error ? "input-error" : ""}`}
        aria-describedby={error ? errorId : undefined}
        aria-invalid={error ? "true" : undefined}
      />
      {error && (
        <p id={errorId} className="error-message" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

aria-invalid="true" tells screen readers the field is in an error state. aria-describedby links the input to its error message. role="alert" causes screen readers to announce it immediately when it appears. Three attributes. That's all it takes.

This is the most commonly broken pattern in React SPAs. And I broke it plenty of times.

When a modal opens, focus must move into it. When it closes, focus must return to the element that opened it. In between, Tab should cycle within the modal — not escape to the page behind it.

tsx
"use client";

import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement;
      dialogRef.current?.focus();
    } else {
      // return focus to whatever opened the modal
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Escape") {
      onClose();
      return;
    }
    if (e.key !== "Tab") return;

    const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    if (!focusable || focusable.length === 0) return;

    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    // trap focus — wrap around at edges
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  };

  if (!isOpen) return null;

  return createPortal(
    <div
      className="fixed inset-0 z-50 flex items-center justify-center"
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <div
        className="fixed inset-0 bg-black/50"
        onClick={onClose}
        aria-hidden="true"
      />
      <div
        ref={dialogRef}
        className="relative z-10 w-full max-w-lg rounded-lg bg-white p-6 shadow-xl"
        onKeyDown={handleKeyDown}
        tabIndex={-1}
      >
        <h2 id="modal-title" className="text-xl font-semibold mb-4">
          {title}
        </h2>
        {children}
        <button
          type="button"
          onClick={onClose}
          className="absolute top-4 right-4 text-gray-500 hover:text-gray-900"
          aria-label="Close modal"
        >
          x
        </button>
      </div>
    </div>,
    document.body
  );
}

In practice, I use Radix UI's Dialog which handles all of this correctly. But you should understand the underlying behavior for debugging. I've spent enough time figuring out why focus wasn't returning to the right button to know this matters.

Keyboard users have to tab through every nav item to reach the main content. On every page load. Skip links fix this:

tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <a
          href="#main-content"
          className="
            sr-only focus:not-sr-only
            fixed top-4 left-4 z-[9999]
            rounded-md bg-blue-600 px-4 py-2 text-white font-medium
            focus:outline-none focus:ring-2 focus:ring-white
          "
        >
          Skip to main content
        </a>
        <SiteHeader />
        <main id="main-content" tabIndex={-1}>
          {children}
        </main>
        <SiteFooter />
      </body>
    </html>
  );
}

Hidden visually via sr-only. Visible when focused with keyboard. Takes about 30 seconds to implement. I added it to my project about two years too late.

Live Regions for Dynamic Content#

When content changes dynamically — a toast appears, search results update, a form submits — screen readers won't know unless you use a live region.

tsx
function SearchResultsCount({ count, query }: { count: number; query: string }) {
  return (
    <p aria-live="polite" aria-atomic="true" className="text-sm text-gray-600">
      {query
        ? `${count} result${count !== 1 ? "s" : ""} for "${query}"`
        : "Enter a search term above"}
    </p>
  );
}
tsx
// Toast notifications — need to be announced
function ToastContainer({ toasts }: { toasts: Toast[] }) {
  return (
    <div
      aria-live="polite"
      aria-label="Notifications"
      className="fixed bottom-4 right-4 z-50 space-y-2"
    >
      {toasts.map((toast) => (
        <div
          key={toast.id}
          role="status"
          className={`rounded-lg px-4 py-3 shadow-lg ${
            toast.type === "error" ? "bg-red-600 text-white" : "bg-gray-900 text-white"
          }`}
        >
          {toast.message}
        </div>
      ))}
    </div>
  );
}

Icon Buttons#

Icon-only buttons are everywhere in modern UIs. They're also the easiest thing to get wrong:

tsx
import { Trash2 } from "lucide-react";

// Screen reader announces "button" with no name. Useless.
<button onClick={handleDelete}>
  <Trash2 className="h-4 w-4" />
</button>

// Add an aria-label. That's it. That's the fix.
<button
  type="button"
  onClick={handleDelete}
  aria-label="Delete item"
>
  <Trash2 className="h-4 w-4" aria-hidden="true" />
</button>

// Or just add visible text next to the icon
<button type="button" onClick={handleDelete} className="flex items-center gap-2">
  <Trash2 className="h-4 w-4" aria-hidden="true" />
  <span>Delete</span>
</button>

Mistakes I Made (So You Don't Have To)#

Using aria-label on non-interactive elements. On a generic <div> it does nothing useful. If you want to label a region, use role="region" with aria-label, or better yet, use a semantic landmark element.

Announcing everything with aria-live="assertive". Assertive interrupts whatever the screen reader is saying. That's disorienting. Use polite for almost everything. Reserve assertive for genuine emergencies like session timeouts.

Relying entirely on automated tools. axe and Lighthouse are good — they catch real issues. But they can't detect whether focus returns to the right place when a modal closes, whether a keyboard user can actually complete a workflow, or whether dynamically inserted content reads in the right order. Manual testing with an actual screen reader is irreplaceable. I wish I'd started doing it sooner.

Forgetting lang on the <html> element. Screen readers use this for pronunciation. My blog was missing it for six months. Every page. En español mi pronunciación de HTML ya era terrible — imagínate cómo la hacía el screen reader sin saber qué idioma usar.

TL;DR#

80% of accessibility issues in React apps fall into four buckets: missing form labels, broken keyboard support on custom elements, busted focus management in modals, and missing text alternatives for icons and images. Fix those four things and you're ahead of most production apps. Use Radix UI or React Aria for the hard parts — they implement focus trapping and ARIA state management correctly so you don't have to reinvent it.

But honestly? The most important thing is to open VoiceOver (or NVDA on Windows) and try to use your own app with your eyes closed. That will teach you more in 30 minutes than any blog post, including this one.

I'm committing to doing this for every feature I ship from now on. I should have started years ago.

Accessibility in React: The Post I Should Have Written Years Ago | Blog