Building Accessible React Components

Building Accessible React Components

A blind user filed a support ticket for our app. Lighthouse said our accessibility score was 94. The user couldn't complete a single task. Here's what I learned about building React components that actually work for everyone.

Last year, a blind user filed a support ticket for an app I helped build. They couldn't complete a basic task -- creating an account. Our Lighthouse accessibility score was 94. Our modal didn't trap focus, our form didn't announce errors, and our custom dropdown was completely invisible to screen readers. Lighthouse said nothing about any of this.

That experience taught me that automated tools catch maybe 30-50% of real accessibility issues. The rest requires understanding how people actually use assistive technology, and building your components with that understanding from the start.

The good news: React's component model is perfect for accessibility. Make a <Button> accessible once, and every instance across your app inherits those properties. The compound effect is massive.

Why Accessibility Matters in React Development

Roughly 15-20% of the world's population has some form of disability. Some users rely on screen readers, some navigate entirely with keyboards, some can't distinguish certain colors, some are sensitive to motion. Accessibility isn't an edge case -- it's a significant portion of your user base.

There are legal reasons too (ADA, European Accessibility Act, WCAG), but if the legal argument motivates you more than "a human being can't use your product," that's a different conversation.

Here's something that surprised me: accessibility improvements often help everyone. When I added proper focus management to our app, keyboard power users -- people who just prefer not touching the mouse -- sent thank-you messages. When we added visible focus indicators, QA testers said their workflow got faster. Curb cuts were designed for wheelchairs but everyone with a stroller or a suitcase uses them. Same principle applies to software.

Semantic HTML: The Stuff You Already Know But Aren't Doing

Before reaching for any ARIA attribute, use the right HTML element. I review React codebases where buttons are <div onClick> and navigation is styled spans. It's like building a house with cardboard and trying to make it weatherproof with duct tape.

// Broken. Not focusable, no keyboard support, not announced as button.
function BadButton({ onClick, children }) {
  return <div className="btn" onClick={onClick}>{children}</div>;
}

// Works. Out of the box. No extra effort.
function GoodButton({ onClick, children, type = "button" }) {
  return <button type={type} className="btn" onClick={onClick}>{children}</button>;
}

That <div> button isn't focusable by default, doesn't respond to Enter or Space, and isn't announced as a button by screen readers. You'd need tabIndex, role="button", and onKeyDown handlers -- or you could just use a <button>.

I once audited a React app that had 47 instances of <div onClick> being used as buttons. Forty-seven. Every single one was inaccessible to keyboard users. The fix took about two hours -- mostly search and replace, plus fixing a few spots where the CSS assumed a div and broke when changed to a button. Two hours of work to make 47 interactive elements accessible. That's the kind of return on investment that makes semantic HTML a no-brainer.

Use <nav>, <main>, <aside>, <header>, <footer>. These landmarks let screen reader users jump between page sections. Use heading levels in order -- never jump from <h1> to <h4> for styling. That's what CSS is for.

Another common mistake I see: using <a> tags without an href. An anchor without href isn't keyboard-focusable and isn't treated as a link by screen readers. If it navigates somewhere, give it an href. If it triggers an action, it should be a <button>. The distinction matters: links go places, buttons do things.

ARIA: Powerful and Dangerous

ARIA fills gaps where semantic HTML can't do the job -- custom widgets, dynamic content, complex interactions. In React, ARIA attributes use kebab-case.

function SearchInput({ results }) {
  return (
    <div>
      <label htmlFor="search">Search articles</label>
      <input
        id="search"
        type="search"
        role="combobox"
        aria-expanded={results.length > 0}
        aria-controls="search-results"
        aria-autocomplete="list"
      />
      {results.length > 0 && (
        <ul id="search-results" role="listbox">
          {results.map((r) => (
            <li key={r.id} role="option">{r.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

The ARIA attributes I use most: aria-label (names elements without visible text), aria-describedby (links to help/error text), aria-expanded (open/closed state), aria-hidden (hides decorative elements), aria-live (announces dynamic changes).

But the first rule of ARIA is: don't use ARIA. If a native HTML element does what you need, use it. ARIA adds semantics without behavior. A <div role="button"> tells screen readers "this is a button" without making it behave like one. That's worse than no ARIA at all because the user hears "button" and expects button behavior -- keyboard activation, focus styling -- but gets nothing.

I've also seen developers overuse aria-hidden="true". It hides elements from screen readers, which sounds useful for decorative icons and such. But I once found a bug where someone put aria-hidden="true" on a parent container, which hid the entire navigation from screen readers. The visual UI looked fine. The automated tests passed. But for anyone using VoiceOver or NVDA, the navigation simply didn't exist. Took us three weeks to catch it because nobody on the team was regularly testing with a screen reader.

Keyboard Navigation

Test every component by unplugging your mouse. If you can't use it with keyboard alone, it's broken. Tab moves between elements, Enter/Space activate things, Escape closes things, Arrows navigate within widgets.

function Dropdown({ isOpen, onClose, items, onSelect }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const menuRef = useRef(null);

  useEffect(() => {
    if (isOpen) menuRef.current?.focus();
  }, [isOpen]);

  const handleKeyDown = (e) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex((i) => Math.min(i + 1, items.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex((i) => Math.max(i - 1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        onSelect(items[activeIndex]);
        onClose();
        break;
      case 'Escape':
        onClose();
        break;
    }
  };

  if (!isOpen) return null;
  return (
    <ul ref={menuRef} role="menu" tabIndex={-1} onKeyDown={handleKeyDown}>
      {items.map((item, i) => (
        <li key={item.id} role="menuitem" aria-selected={i === activeIndex}>
          {item.label}
        </li>
      ))}
    </ul>
  );
}

A subtle thing in that dropdown code: e.preventDefault() on arrow keys prevents the page from scrolling while the user navigates the menu. Without it, pressing ArrowDown moves the menu selection AND scrolls the page, which is disorienting. It's the kind of detail you only learn by actually using the keyboard to navigate your own components.

Don't forget SPA route changes. When React Router navigates, focus stays where it was. Move focus to the new page's heading so screen reader users know the page changed. I use a custom hook for this:

function usePageFocus(title) {
  const headingRef = useRef(null);
  
  useEffect(() => {
    if (headingRef.current) {
      headingRef.current.focus();
      document.title = title;
    }
  }, [title]);
  
  return headingRef;
}

// In a page component
function DashboardPage() {
  const headingRef = usePageFocus('Dashboard - MyApp');
  
  return (
    <main>
      <h1 ref={headingRef} tabIndex={-1}>Dashboard</h1>
      {/* page content */}
    </main>
  );
}

The tabIndex={-1} on the heading makes it programmatically focusable without adding it to the tab order. Screen reader users hear "Dashboard, heading level 1" and know they're on a new page.

Modal Focus Traps

Modals need three things: focus moves INTO the modal on open, focus is TRAPPED inside while open, and focus returns to the TRIGGER on close. Most implementations miss at least one.

function Modal({ isOpen, onClose, title, children }) {
  const modalRef = useRef(null);
  const triggerRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      triggerRef.current = document.activeElement;
      modalRef.current?.focus();
      document.body.style.overflow = 'hidden';
    }
    return () => {
      document.body.style.overflow = '';
      triggerRef.current?.focus();
    };
  }, [isOpen]);

  // Focus trap: Tab cycles within modal
  const trapFocus = (e) => {
    if (e.key !== 'Tab') return;
    const focusable = modalRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
    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 (
    <div className="overlay" onClick={onClose}>
      <div ref={modalRef} role="dialog" aria-modal="true"
           aria-labelledby="modal-title" tabIndex={-1}
           onKeyDown={(e) => { if (e.key === 'Escape') onClose(); trapFocus(e); }}
           onClick={(e) => e.stopPropagation()}>
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose} aria-label="Close dialog">Close</button>
      </div>
    </div>
  );
}

There's a bug in the modal code above that I want to point out because it's incredibly common: the id="modal-title" is hardcoded. If you render two modals on the same page, you have duplicate IDs and aria-labelledby points to the wrong one. Use React 18's useId to generate unique IDs, or pass the ID as a prop.

Also worth noting: aria-modal="true" tells screen readers that content behind the modal is inert. But browser support is inconsistent. For belt-and-suspenders safety, also add aria-hidden="true" to the rest of the page content when a modal is open. The inert HTML attribute is the modern solution to this, and it's now supported in all major browsers.

Forms: Where Most Apps Fail Hardest

React 18's useId generates unique, SSR-safe IDs for form fields:

function FormField({ label, error, helpText, ...props }) {
  const id = useId();
  const errorId = \`\${id}-error\`;
  const helpId = \`\${id}-help\`;

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-invalid={!!error}
        aria-describedby={
          [error && errorId, helpText && helpId].filter(Boolean).join(' ') || undefined
        }
        {...props}
      />
      {helpText && <p id={helpId}>{helpText}</p>}
      {error && <p id={errorId} role="alert">{error}</p>}
    </div>
  );
}

role="alert" on errors makes screen readers announce them immediately. aria-invalid tells assistive tech "this field has a problem." The filter(Boolean).join(' ') pattern only includes IDs for elements that exist.

One thing I see teams get wrong with forms: they validate on submit only and show all errors at the top of the form. Screen reader users hear a list of errors but then have to figure out which field each error corresponds to. The pattern above -- inline errors linked to their field via aria-describedby -- is much better. When the user focuses a field, the screen reader announces the field label, then the error message. They know exactly what's wrong and where.

Inline validation timing matters too. Don't show errors while the user is still typing -- it's annoying for everyone and especially disruptive for screen reader users who hear error announcements mid-word. Validate on blur (when the user leaves the field) or on submit. I use a simple pattern: fields start in a "pristine" state, transition to "touched" on blur, and only show errors when touched.

Skip links let keyboard users bypass navigation:

<a href="#main-content" className="skip-link">Skip to main content</a>
// ... navigation ...
<main id="main-content" tabIndex={-1}>{children}</main>

The CSS for a skip link hides it off-screen until it receives focus. Sighted keyboard users see it flash into view when they Tab into the page, and can press Enter to jump past the navigation. Screen reader users hear it as the first focusable element on the page.

.skip-link {
  position: absolute;
  left: -9999px;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
.skip-link:focus {
  position: fixed;
  top: 10px;
  left: 10px;
  width: auto;
  height: auto;
  padding: 12px 24px;
  background: #000;
  color: #fff;
  z-index: 9999;
  font-size: 16px;
}

Live regions announce dynamic changes:

<div aria-live="polite">
  {notifications.map((n) => <p key={n.id}>{n.message}</p>)}
</div>

Use polite for non-urgent updates (waits for screen reader to finish), assertive for urgent errors (interrupts). Don't make everything assertive -- it's like someone constantly shouting over you.

A gotcha with live regions: the aria-live container must exist in the DOM before content is added to it. If you conditionally render the container along with its content, the screen reader doesn't see the announcement because it wasn't watching that element. Keep the container always rendered and add/remove content inside it.

Color and Motion

WCAG requires 4.5:1 contrast for normal text, 3:1 for large text. Don't rely on color alone to convey information -- roughly 8% of men have color vision deficiency.

// Bad: color is the only indicator
<span style={{ color: isActive ? 'green' : 'red' }}>●</span>

// Good: color + text + icon
<span role="status">
  {isActive ? '✓ Active' : '✗ Inactive'}
</span>

Respect prefers-reduced-motion for users who experience motion sickness from animations:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

In React, you can detect this with a hook and conditionally apply animations:

function usePrefersReducedMotion() {
  const [reduced, setReduced] = useState(false);
  
  useEffect(() => {
    const query = window.matchMedia('(prefers-reduced-motion: reduce)');
    setReduced(query.matches);
    const handler = (e) => setReduced(e.matches);
    query.addEventListener('change', handler);
    return () => query.removeEventListener('change', handler);
  }, []);
  
  return reduced;
}

React Aria: Don't Reinvent the Wheel

I spent two weeks building an accessible combobox from scratch. 400 lines of keyboard handling and ARIA management. Then I found Adobe's React Aria library and replaced it with 30 lines. React Aria provides hooks for every common UI pattern -- comboboxes, date pickers, tables, dialogs -- with correct keyboard handling and ARIA attributes.

The reason I recommend React Aria specifically over other libraries: it's headless. It gives you behavior and accessibility without any styling. You bring your own CSS, your own design system, your own component structure. It handles the hard parts -- keyboard interactions, screen reader announcements, focus management -- and stays out of the way for everything else.

My recommendation: build simple components (buttons, links, forms) with semantic HTML. For complex widgets -- comboboxes, date pickers, tables with sorting -- use React Aria. The edge cases in keyboard navigation alone for a combobox would take you weeks to handle correctly. React Aria's team has already done that work and tested it across browsers and screen readers.

Testing Accessibility

My testing workflow:

  1. eslint-plugin-jsx-a11y -- catches issues during development. It'll flag missing alt text, invalid ARIA attributes, and non-interactive elements with click handlers. Not perfect, but it catches the obvious stuff before code review.
  2. axe-core -- runs in the browser during development. I use the browser extension during manual testing and @axe-core/react in development mode to log violations to the console.
  3. React Testing Library -- use getByRole and getByLabelText. If you can't find your element with accessible queries, that IS the bug. I've started treating a failing getByRole query as an accessibility issue rather than a test-writing problem.
  4. Manual screen reader testing -- VoiceOver on Mac, NVDA on Windows, TalkBack on Android. No automated tool replaces this. The first time I used VoiceOver to navigate my own app, I was embarrassed by how bad the experience was. That embarrassment was productive.

Start with one thing. Go find a <div onClick> in your codebase and replace it with a <button>. Then add a skip link. Then test your most important form with a screen reader. You'll be appalled at what you find, and that's okay. The important thing is that you started looking.

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (0)

No comments yet. Be the first to share your thoughts!