Skip to content

Building Accessible React Components from the Ground Up

A guide to building React components with accessibility first: ARIA patterns, keyboard navigation, focus management and screen reader testing.

4 min read
React component tree with accessibility annotations showing ARIA roles and keyboard focus flow

Accessibility Is Not a Feature—It Is a Baseline

Accessibility is not something you add to a finished component. If a button does not work with a keyboard, it is not a complete button. If a modal traps focus incorrectly, it is a broken modal. Accessibility is the minimum bar for a component being functional, not an enhancement you bolt on before an audit.

The good news: building accessible React components is not harder than building inaccessible ones. It requires understanding a few patterns and applying them consistently. The patterns become second nature after a few components.

Semantic HTML as the Foundation

The most impactful accessibility improvement is using the correct HTML element. A <button> gets keyboard interaction, focus management, and screen reader announcements for free. A <div onClick> gets none of them.

tsxtsx
// ❌ Custom div pretending to be a button
function BadButton({ onClick, children }: {
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <div
      className="btn"
      onClick={onClick}
    >
      {children}
    </div>
  );
  // Missing: keyboard support, focus, role, tabindex
}
 
// ✅ Actual button element — accessible by default
function GoodButton({ onClick, children, disabled = false }: {
  onClick: () => void;
  children: React.ReactNode;
  disabled?: boolean;
}) {
  return (
    <button
      className="btn"
      onClick={onClick}
      disabled={disabled}
      type="button"
    >
      {children}
    </button>
  );
  // Gets for free: focus, keyboard activation, disabled state,
  // screen reader role announcement
}

Before reaching for ARIA attributes, ask whether the correct HTML element already provides what you need. In most cases, it does.

Keyboard Navigation Patterns

Every interactive element must be operable with a keyboard. This means handling focus order, arrow key navigation within composite widgets, and escape keys for dismissible elements.

tsxtsx
import { useRef, useCallback, KeyboardEvent } from "react";
 
interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
}
 
function Tabs({ items }: { items: TabItem[] }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
 
  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      let newIndex = activeIndex;
 
      switch (event.key) {
        case "ArrowRight":
          newIndex = (activeIndex + 1) % items.length;
          break;
        case "ArrowLeft":
          newIndex = (activeIndex - 1 + items.length) % items.length;
          break;
        case "Home":
          newIndex = 0;
          break;
        case "End":
          newIndex = items.length - 1;
          break;
        default:
          return;
      }
 
      event.preventDefault();
      setActiveIndex(newIndex);
      tabRefs.current[newIndex]?.focus();
    },
    [activeIndex, items.length]
  );
 
  return (
    <div>
      <div
        role="tablist"
        aria-label="Content tabs"
        onKeyDown={handleKeyDown}
      >
        {items.map((item, index) => (
          <button
            key={item.id}
            ref={(el) => { tabRefs.current[index] = el; }}
            role="tab"
            id={`tab-${item.id}`}
            aria-selected={index === activeIndex}
            aria-controls={`panel-${item.id}`}
            tabIndex={index === activeIndex ? 0 : -1}
            onClick={() => setActiveIndex(index)}
          >
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item, index) => (
        <div
          key={item.id}
          role="tabpanel"
          id={`panel-${item.id}`}
          aria-labelledby={`tab-${item.id}`}
          hidden={index !== activeIndex}
          tabIndex={0}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}

The tab component follows the WAI-ARIA Tabs pattern: arrow keys move between tabs, only the active tab is in the tab order (tabIndex={0}), and inactive tabs are removed from the tab order (tabIndex={-1}).

Focus Management for Modals and Dialogs

Modals must trap focus within their boundaries and return focus to the trigger element when closed. Without this, keyboard users get lost behind the modal overlay.

tsxtsx
import { useEffect, useRef, useCallback } from "react";
 
function useFocusTrap(isOpen: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);
 
  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement;
 
      const focusableSelector =
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
 
      const container = containerRef.current;
      if (!container) return;
 
      const focusableElements = container.querySelectorAll(focusableSelector);
      const firstElement = focusableElements[0] as HTMLElement;
      firstElement?.focus();
 
      return () => {
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen]);
 
  const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
    if (event.key !== "Tab") return;
 
    const container = containerRef.current;
    if (!container) return;
 
    const focusableSelector =
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
    const focusableElements = container.querySelectorAll(focusableSelector);
    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[
      focusableElements.length - 1
    ] as HTMLElement;
 
    if (event.shiftKey && document.activeElement === firstElement) {
      event.preventDefault();
      lastElement.focus();
    } else if (!event.shiftKey && document.activeElement === lastElement) {
      event.preventDefault();
      firstElement.focus();
    }
  }, []);
 
  return { containerRef, handleKeyDown };
}
 
function Modal({
  isOpen,
  onClose,
  title,
  children,
}: {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}) {
  const { containerRef, handleKeyDown } = useFocusTrap(isOpen);
 
  if (!isOpen) return null;
 
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={containerRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        onKeyDown={(e) => {
          handleKeyDown(e);
          if (e.key === "Escape") onClose();
        }}
        onClick={(e) => e.stopPropagation()}
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

Live Regions for Dynamic Content

When content updates dynamically—form validation errors, notification toasts, loading states—screen readers need to be told that something changed. ARIA live regions handle this.

tsxtsx
import { useState } from "react";
 
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);
  const [isLoading, setIsLoading] = useState(false);
 
  return (
    <div>
      {/* Polite announcement for search results count */}
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {isLoading
          ? "Searching..."
          : `${results.length} results found for "${query}"`}
      </div>
 
      {/* Results list */}
      <ul aria-label={`Search results for ${query}`}>
        {results.map((result, i) => (
          <li key={i}>{result}</li>
        ))}
      </ul>
    </div>
  );
}
 
function FormWithValidation() {
  const [errors, setErrors] = useState<Record<string, string>>({});
 
  return (
    <form>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && (
          <div
            id="email-error"
            role="alert"
            className="error-message"
          >
            {errors.email}
          </div>
        )}
      </div>
    </form>
  );
}

Use aria-live="polite" for non-urgent updates (search results, status changes) and role="alert" for important messages that need immediate attention (validation errors, error states).

Testing Accessibility

Accessible components need automated tests that verify ARIA attributes, keyboard interactions, and focus management.

tstypescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
 
describe("Tabs component", () => {
  const items = [
    { id: "1", label: "Tab 1", content: <p>Content 1</p> },
    { id: "2", label: "Tab 2", content: <p>Content 2</p> },
    { id: "3", label: "Tab 3", content: <p>Content 3</p> },
  ];
 
  it("supports arrow key navigation", async () => {
    const user = userEvent.setup();
    render(<Tabs items={items} />);
 
    const firstTab = screen.getByRole("tab", { name: "Tab 1" });
    await user.click(firstTab);
 
    await user.keyboard("{ArrowRight}");
    expect(screen.getByRole("tab", { name: "Tab 2" })).toHaveFocus();
 
    await user.keyboard("{ArrowRight}");
    expect(screen.getByRole("tab", { name: "Tab 3" })).toHaveFocus();
 
    // Wraps around
    await user.keyboard("{ArrowRight}");
    expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveFocus();
  });
 
  it("sets correct ARIA attributes", () => {
    render(<Tabs items={items} />);
 
    const activeTab = screen.getByRole("tab", { name: "Tab 1" });
    expect(activeTab).toHaveAttribute("aria-selected", "true");
    expect(activeTab).toHaveAttribute("tabindex", "0");
 
    const inactiveTab = screen.getByRole("tab", { name: "Tab 2" });
    expect(inactiveTab).toHaveAttribute("aria-selected", "false");
    expect(inactiveTab).toHaveAttribute("tabindex", "-1");
  });
 
  it("shows correct panel when tab is selected", async () => {
    const user = userEvent.setup();
    render(<Tabs items={items} />);
 
    expect(screen.getByText("Content 1")).toBeVisible();
    expect(screen.queryByText("Content 2")).not.toBeVisible();
 
    await user.click(screen.getByRole("tab", { name: "Tab 2" }));
    expect(screen.getByText("Content 2")).toBeVisible();
  });
});

Automated testing catches regressions in ARIA attributes and keyboard behavior. Combine this with manual screen reader testing on at least one screen reader (VoiceOver on macOS, NVDA on Windows) for each new component pattern.

Key Takeaways

Accessibility starts with semantic HTML. Use the correct elements before reaching for ARIA—a <button> is more accessible than any number of ARIA attributes on a <div>. Build keyboard navigation following WAI-ARIA patterns so users get consistent, predictable behavior across components.

Focus management is critical for modals, dropdowns, and any component that creates a new interaction context. Live regions keep screen reader users informed about dynamic content changes. Test accessibility with automated tools for ARIA attributes and keyboard interactions, supplemented by manual screen reader testing.

Building accessible components is not extra work—it is the work of building components correctly.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX