DNA⚛️ ReactAccessibility in React
🐣HatchlingReactAccessibilityARIAKeyboard NavigationScreen Readers

Accessibility in React

ARIA roles, keyboard navigation, screen readers — build apps that everyone can use, not just some people.

Accessibility in React

Think of it like this: You build a beautiful restaurant, but the front door is too narrow for wheelchairs. The food is amazing, but some customers literally can't get in. That's what an inaccessible website is — a great product that excludes people.

Accessibility (a11y) means building apps that everyone can use — people who are blind, deaf, have motor disabilities, or use assistive technologies. And here's the thing: it's not optional.

What Is ARIA?

ARIA stands for Accessible Rich Internet Applications. It's a set of HTML attributes that tell screen readers and assistive technologies what your UI elements are and what they do.

Think of it like this: HTML gives you a <div>, but a screen reader doesn't know if that <div> is a button, a menu, or a dialog. ARIA attributes are like name tags — they introduce your elements to assistive technologies.

function SearchBox() {
  return (
    <div role="search" aria-label="Site search">
      <input
        type="text"
        aria-label="Search articles"
        placeholder="Search..."
      />
      <button aria-label="Submit search">
        🔍
      </button>
    </div>
  );
}

Without aria-label, a screen reader seeing that button would just say "button" — the user has no idea what it does. With it, the screen reader says "Submit search, button."

The First Rule of ARIA

Common Mistake: Don't use ARIA when native HTML already does the job. A <button> is already accessible. A <div onClick={...}> is not — and slapping role="button" on it is a band-aid, not a fix. Use the right HTML element first.

// ❌ Bad — div pretending to be a button
<div role="button" tabIndex={0} onClick={handleClick}>
  Click me
</div>
 
// ✅ Good — just use a button
<button onClick={handleClick}>
  Click me
</button>

Native HTML elements come with built-in keyboard support, focus management, and screen reader announcements for free. ARIA is for when native HTML isn't enough — like custom widgets.

Common ARIA Attributes

AttributeWhat It DoesExample
roleTells assistive tech what an element isrole="dialog", role="alert"
aria-labelGives an invisible labelaria-label="Close menu"
aria-labelledbyPoints to another element as the labelaria-labelledby="heading-id"
aria-describedbyPoints to a description elementaria-describedby="error-msg"
aria-hiddenHides element from screen readersaria-hidden="true" on decorative icons
aria-liveAnnounces dynamic content changesaria-live="polite" for status updates
aria-expandedTells if a section is open or closedaria-expanded="true" on accordions
aria-requiredMarks a field as requiredaria-required="true"

Why Accessibility Matters

It's not just about being nice (though that matters too):

1. It's the law. In many countries, inaccessible websites violate disability discrimination laws (ADA in the US, EAA in Europe). Companies get sued — a lot.

2. It's a huge audience. About 15% of the world's population has some form of disability. That's over a billion people.

3. It makes your app better for everyone. Keyboard navigation helps power users. Captions help people in noisy environments. High contrast helps people in bright sunlight. Good accessibility = good UX.

function StatusMessage({ message }: { message: string }) {
  return (
    <div role="status" aria-live="polite">
      {message}
    </div>
  );
}

When message changes, screen readers will announce it automatically because of aria-live="polite". Without this, dynamic updates are completely invisible to screen reader users.

Making Forms Accessible

Forms are where accessibility matters most — and where most developers mess up. Think of it like this: a sighted user can glance at a form and understand it instantly. A screen reader user hears one field at a time, in order, with no visual context. Your code needs to provide that context.

Labels Are Non-Negotiable

function SignupForm() {
  return (
    <form>
      {/* ❌ Bad — screen reader says "edit text, blank" */}
      <input type="email" placeholder="Email" />
 
      {/* ✅ Good — screen reader says "Email address, edit text" */}
      <label htmlFor="email">Email address</label>
      <input id="email" type="email" placeholder="you@example.com" />
    </form>
  );
}

Common Mistake: Using placeholder instead of <label>. Placeholders disappear when you start typing, and many screen readers skip them entirely. Always use a visible <label> with htmlFor matching the input's id.

Accessible Error Messages

When a form field has an error, sighted users see the red text. Screen reader users need to be told about it explicitly:

function EmailField() {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");
 
  const validate = () => {
    if (!email.includes("@")) {
      setError("Please enter a valid email address");
    } else {
      setError("");
    }
  };
 
  return (
    <div>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        onBlur={validate}
        aria-invalid={error ? "true" : "false"}
        aria-describedby={error ? "email-error" : undefined}
      />
      {error && (
        <span id="email-error" role="alert">
          {error}
        </span>
      )}
    </div>
  );
}

Here's what's happening:

  • aria-invalid="true" tells the screen reader "this field has an error"
  • aria-describedby="email-error" links the input to its error message
  • role="alert" makes the screen reader announce the error immediately when it appears

Focus Management

When something goes wrong, move focus to where the user needs to act. Think of it like a tour guide pointing to the right door instead of making you wander the hallway.

function LoginForm() {
  const emailRef = useRef<HTMLInputElement>(null);
  const [errors, setErrors] = useState<string[]>([]);
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const newErrors: string[] = [];
 
    if (!emailRef.current?.value) {
      newErrors.push("Email is required");
    }
 
    if (newErrors.length > 0) {
      setErrors(newErrors);
      emailRef.current?.focus();
    }
  };
 
  return (
    <form onSubmit={handleSubmit} noValidate>
      {errors.length > 0 && (
        <div role="alert" aria-label="Form errors">
          <ul>
            {errors.map((err) => (
              <li key={err}>{err}</li>
            ))}
          </ul>
        </div>
      )}
      <label htmlFor="login-email">Email</label>
      <input ref={emailRef} id="login-email" type="email" />
      <button type="submit">Log in</button>
    </form>
  );
}

When submission fails, focus jumps to the first problematic field so the user knows exactly where to fix things.

How to Make an Autocomplete Accessible

Building a custom autocomplete is one of the hardest accessibility challenges in web development. A native <select> gives you all of this for free, but when you need a custom dropdown with search, you have to implement it yourself.

Think of it like this: a sighted user sees a text box, types, and sees suggestions appear. A screen reader user needs to know: "I'm in a combo box. There are 5 suggestions. The first one is highlighted. I can press arrow keys to move through them."

The ARIA Pattern: Combobox

function Autocomplete() {
  const [query, setQuery] = useState("");
  const [suggestions, setSuggestions] = useState<string[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
 
  const filteredSuggestions = suggestions.filter((s) =>
    s.toLowerCase().includes(query.toLowerCase())
  );
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setActiveIndex((prev) =>
          prev < filteredSuggestions.length - 1 ? prev + 1 : prev
        );
        break;
      case "ArrowUp":
        e.preventDefault();
        setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev));
        break;
      case "Enter":
        e.preventDefault();
        if (activeIndex >= 0) {
          setQuery(filteredSuggestions[activeIndex]);
          setIsOpen(false);
          setActiveIndex(-1);
        }
        break;
      case "Escape":
        setIsOpen(false);
        setActiveIndex(-1);
        inputRef.current?.focus();
        break;
    }
  };
 
  const selectSuggestion = (suggestion: string) => {
    setQuery(suggestion);
    setIsOpen(false);
    setActiveIndex(-1);
    inputRef.current?.focus();
  };
 
  return (
    <div>
      <label htmlFor="search-input">Search cities</label>
      <div role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
        <input
          ref={inputRef}
          id="search-input"
          type="text"
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            setIsOpen(true);
            setActiveIndex(-1);
          }}
          onKeyDown={handleKeyDown}
          aria-autocomplete="list"
          aria-controls="suggestions-list"
          aria-activedescendant={
            activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
          }
        />
        {isOpen && filteredSuggestions.length > 0 && (
          <ul
            ref={listRef}
            id="suggestions-list"
            role="listbox"
            aria-label="Search suggestions"
          >
            {filteredSuggestions.map((suggestion, index) => (
              <li
                key={suggestion}
                id={`suggestion-${index}`}
                role="option"
                aria-selected={index === activeIndex}
                onClick={() => selectSuggestion(suggestion)}
              >
                {suggestion}
              </li>
            ))}
          </ul>
        )}
      </div>
      <div aria-live="polite" className="sr-only">
        {isOpen && filteredSuggestions.length > 0
          ? `${filteredSuggestions.length} suggestions available`
          : ""}
      </div>
    </div>
  );
}

Let's break down the key ARIA attributes:

AttributePurpose
role="combobox"Tells screen readers this is an input + dropdown combo
aria-expandedWhether the dropdown is open or closed
aria-haspopup="listbox"Indicates a dropdown will appear
aria-autocomplete="list"Tells screen reader suggestions will appear as a list
aria-controlsLinks the input to the suggestions list
aria-activedescendantTells screen reader which suggestion is highlighted
role="listbox"The suggestions container is a selectable list
role="option"Each suggestion is a selectable option
aria-selectedWhich option is currently highlighted
aria-live="polite"Announces the number of results without interrupting

Keyboard Navigation Patterns

Think of it like this: some users can't use a mouse at all. Everything they do — clicking buttons, opening menus, selecting options — happens through the keyboard. If your widget doesn't work with a keyboard, it doesn't work for them.

Essential Keyboard Patterns

Every interactive element should support these:

function AccessibleTabs() {
  const [activeTab, setActiveTab] = useState(0);
  const tabs = ["Profile", "Settings", "Notifications"];
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
 
  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;
 
    switch (e.key) {
      case "ArrowRight":
        newIndex = (index + 1) % tabs.length;
        break;
      case "ArrowLeft":
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case "Home":
        newIndex = 0;
        break;
      case "End":
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }
 
    e.preventDefault();
    setActiveTab(newIndex);
    tabRefs.current[newIndex]?.focus();
  };
 
  return (
    <div>
      <div role="tablist" aria-label="Account settings">
        {tabs.map((tab, index) => (
          <button
            key={tab}
            ref={(el) => { tabRefs.current[index] = el; }}
            role="tab"
            aria-selected={index === activeTab}
            aria-controls={`panel-${index}`}
            tabIndex={index === activeTab ? 0 : -1}
            onClick={() => setActiveTab(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab}
          </button>
        ))}
      </div>
      {tabs.map((tab, index) => (
        <div
          key={tab}
          id={`panel-${index}`}
          role="tabpanel"
          aria-labelledby={tab}
          hidden={index !== activeTab}
        >
          Content for {tab}
        </div>
      ))}
    </div>
  );
}

The Keyboard Navigation Cheat Sheet

WidgetKeysBehavior
ButtonsEnter, SpaceActivate
LinksEnterNavigate
TabsArrow Left/RightSwitch tabs
MenusArrow Up/DownMove through items
DialogsEscapeClose
DropdownsArrow Up/Down, EnterNavigate and select
All widgetsTabMove to next focusable element
All widgetsShift+TabMove to previous focusable element

Focus Trapping in Modals

When a modal is open, pressing Tab should cycle through elements inside the modal only — not escape to the page behind it.

function Modal({ isOpen, onClose, children }: {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);
 
  useEffect(() => {
    if (isOpen) {
      previousFocus.current = document.activeElement as HTMLElement;
      const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
    } else {
      previousFocus.current?.focus();
    }
  }, [isOpen]);
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Escape") {
      onClose();
      return;
    }
 
    if (e.key !== "Tab") return;
 
    const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
 
    if (!focusableElements || focusableElements.length === 0) return;
 
    const first = focusableElements[0];
    const last = focusableElements[focusableElements.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="modal-overlay" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        onKeyDown={handleKeyDown}
        onClick={(e) => e.stopPropagation()}
      >
        <h2 id="modal-title">Confirm Action</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

Key things happening here:

  • Save previous focus — When the modal opens, we remember what was focused before
  • Move focus into modal — First focusable element gets focus automatically
  • Trap focus — Tab wraps from last to first element (and vice versa with Shift+Tab)
  • Restore focus — When the modal closes, focus returns to where it was
  • Escape to close — Standard keyboard pattern for dismissing dialogs

Screen Reader Basics

Think of it like this: a screen reader is like having someone read a website to you over the phone. They can only read what's in the HTML — they can't see colors, layout, or visual cues. Your code needs to paint the picture with words.

What Screen Readers Actually Read

// Screen reader says: "navigation, main menu, list with 3 items,
// Home link, About link, current page Contact link"
function Navigation() {
  return (
    <nav aria-label="Main menu">
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/about">About</a></li>
        <li><a href="/contact" aria-current="page">Contact</a></li>
      </ul>
    </nav>
  );
}

Visually Hidden Text

Sometimes you need text for screen readers that shouldn't be visible:

const srOnlyStyle: React.CSSProperties = {
  position: "absolute",
  width: "1px",
  height: "1px",
  padding: 0,
  margin: "-1px",
  overflow: "hidden",
  clip: "rect(0, 0, 0, 0)",
  whiteSpace: "nowrap",
  borderWidth: 0,
};
 
function IconButton({ onClick }: { onClick: () => void }) {
  return (
    <button onClick={onClick}>
      <span aria-hidden="true">🗑️</span>
      <span style={srOnlyStyle}>Delete item</span>
    </button>
  );
}

The emoji is hidden from screen readers (decorative), and the visually hidden text provides the label.

Live Regions for Dynamic Content

When content changes dynamically (like notifications, form validation, or loading states), screen readers need to be told:

function NotificationCenter() {
  const [notifications, setNotifications] = useState<string[]>([]);
 
  const addNotification = (message: string) => {
    setNotifications((prev) => [...prev, message]);
  };
 
  return (
    <div>
      <button onClick={() => addNotification("File saved successfully")}>
        Save
      </button>
 
      {/* aria-live="polite" waits for a pause before announcing */}
      {/* aria-live="assertive" interrupts whatever is being read */}
      <div aria-live="polite" aria-atomic="true">
        {notifications.length > 0 && (
          <p>{notifications[notifications.length - 1]}</p>
        )}
      </div>
    </div>
  );
}
aria-live valueWhen to use
politeStatus updates, search results count, non-urgent info
assertiveError messages, warnings, time-sensitive alerts
offDefault — no announcements

Common Accessibility Mistakes

These are the mistakes that show up in almost every codebase:

1. Click Handlers on Divs

// ❌ Not accessible — can't be focused, no keyboard support
<div onClick={handleClick}>Click me</div>
 
// ✅ Accessible — focus, keyboard, and screen reader support built in
<button onClick={handleClick}>Click me</button>

2. Missing Alt Text on Images

// ❌ Screen reader says "image" — useless
<img src="/chart.png" />
 
// ✅ Informative image — describe what it shows
<img src="/chart.png" alt="Sales increased 25% from January to March" />
 
// ✅ Decorative image — empty alt to skip it
<img src="/decorative-swirl.png" alt="" />

Common Mistake: Writing alt text like alt="image" or alt="photo". The screen reader already says "image" — your alt text should describe what the image shows, not what it is.

3. Poor Color Contrast

// ❌ Light gray text on white background — hard to read
<p style={{ color: "#999", backgroundColor: "#fff" }}>Important info</p>
 
// ✅ Meets WCAG AA contrast ratio (4.5:1 for normal text)
<p style={{ color: "#595959", backgroundColor: "#fff" }}>Important info</p>

4. Auto-Playing Media

// ❌ Starts playing immediately — disorienting for screen reader users
<video autoPlay src="/intro.mp4" />
 
// ✅ User controls playback
<video controls src="/intro.mp4">
  <track kind="captions" src="/captions.vtt" label="English" />
</video>

5. Not Managing Focus After Route Changes

In single-page apps, when the route changes, screen reader users don't know the page updated. They're still focused on the link they clicked.

function useAnnounceRouteChange() {
  const [announcement, setAnnouncement] = useState("");
 
  useEffect(() => {
    setAnnouncement(`Navigated to ${document.title}`);
  }, []);
 
  return (
    <div
      role="status"
      aria-live="assertive"
      aria-atomic="true"
      style={{
        position: "absolute",
        width: "1px",
        height: "1px",
        overflow: "hidden",
        clip: "rect(0, 0, 0, 0)",
      }}
    >
      {announcement}
    </div>
  );
}

6. Forgetting Heading Hierarchy

// ❌ Skipping heading levels confuses screen reader navigation
<h1>My App</h1>
<h3>Welcome</h3>  {/* Where's h2? */}
<h5>Features</h5>  {/* Where's h4? */}
 
// ✅ Sequential heading levels create a logical outline
<h1>My App</h1>
<h2>Welcome</h2>
<h3>Features</h3>

Screen reader users navigate pages by jumping between headings — it's like a table of contents. Skipping levels breaks that navigation.

Quick Accessibility Checklist

Before shipping any feature, run through this:

CheckHow to Test
Can you Tab through everything?Put your mouse away and use only keyboard
Do all images have meaningful alt text?Inspect images in DevTools
Are all form fields labeled?Check that every input has a <label>
Does the color contrast pass?Use browser DevTools accessibility audit
Do dynamic changes get announced?Turn on a screen reader (VoiceOver on Mac: Cmd+F5)
Can you close modals with Escape?Test keyboard interaction
Is focus visible at all times?Tab through and check for a visible focus ring
Does heading hierarchy make sense?Check with a heading outline tool

Common Mistake: Removing the default focus outline with outline: none for aesthetic reasons. If you remove it, you must replace it with a custom visible focus indicator. Otherwise, keyboard users can't see where they are on the page.