Accessible Component Library in React From Scratch
AI generated
</>
{ }
React · WCAG · ARIA · Accessibility
Accessible Component Library
building it yourself in React from scratch

An accessible component library does not come from bolting ARIA on afterward, but from focus management, keyboard control, and semantic structure that are considered from the very first line. Whoever understands the underlying patterns behind roving tabindex, live regions, and focus trap can build components that meet WCAG without constantly consulting an external library.

19 min read WCAG · ARIA · Focus Management · axe-core React 19 · TypeScript

1. What an accessible component library actually needs

An accessible component library differs from an ordinary component library in that keyboard control, focus management, and screen reader semantics are part of the design from the start, not an afterthought patch. The most common mistake in grown codebases is building a component purely visually and only adding accessibility once an audit demands it. By that point, the interaction logic is usually so deeply intertwined with the rendering that a real fix requires a fundamental rework.

The economic reason to build an accessible component library correctly from scratch is simple: baking in accessibility directly costs barely more development time than a purely visual component, while fixing accessibility gaps after the fact in dozens of components that already use this library is many times more expensive. In addition, many markets are now legally required to provide accessibility, which turns the topic from an optional quality improvement into a compliance requirement.

2. WCAG as the foundation: principles instead of a checklist

The Web Content Accessibility Guidelines define four core principles that every accessible component library should use as a thinking framework, instead of treating WCAG as a mere checklist: perceivable, operable, understandable, and robust. Perceivable means information is available through more than one sensory channel, for example text as an alternative to pure color coding. Operable means every function is reachable by keyboard, not only by mouse.

Understandable means predictable behavior: a button behaves like a button, a link like a link, regardless of visual design. Robust means components work with different assistive technologies, not just the one screen reader you happened to test with. Whoever uses these four principles as guardrails for every new component in the accessible component library needs to consult specific WCAG success criteria less often, because the right solution usually follows directly from the principle.

3. Focus management: visible focus and order

The visible focus ring is one of the most frequently, accidentally removed features in frontend projects, usually because outline: none is set for purely aesthetic reasons without defining a replacement. An accessible component library must never remove the native focus ring without a functionally equivalent replacement. The modern solution is the CSS pseudo class :focus-visible, which shows the focus ring only during keyboard navigation and suppresses it on mouse clicks, satisfying both user groups.

Focus order, meaning the order in which Tab moves through interactive elements, must match the visual order. CSS positioning that changes the visual layout without adjusting the DOM order leads to a focus order that confusingly deviates from left to right or top to bottom for keyboard users. An accessible component library tests every component explicitly with pure keyboard navigation before it is considered done, not only with the mouse.


// focus-visible.css — never remove the focus ring without a real replacement
:focus {
  outline: none; /* only ever paired with :focus-visible below */
}

:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* Utility class for components that need a custom focus ring shape */
.focus-ring {
  &:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
    border-radius: 4px;
  }
}

4. Roving tabindex for composite widgets

Composite widgets such as toolbars, radio groups, or listboxes need a special focus pattern so that the browser's tab key does not jump through every single item, but treats the entire group as a single tab stop. This pattern is called roving tabindex: within the group, exactly one element always has tabIndex={0}, all others have tabIndex={-1}. Arrow keys move the active focus within the group, shifting the tabIndex along and setting focus programmatically.

An accessible component library implements roving tabindex best as a reusable hook, so the logic is not rewritten in every component that needs this pattern. The hook manages the active index, reacts to ArrowUp, ArrowDown, Home, and End, and returns ready made props for the container and items that contain the correct tabIndex and onKeyDown values.


// useRovingTabindex.js — reusable focus pattern for composite widgets
import { useCallback, useState } from "react";

export function useRovingTabindex(itemCount) {
  const [activeIndex, setActiveIndex] = useState(0);

  const onKeyDown = useCallback(
    (event) => {
      switch (event.key) {
        case "ArrowDown":
        case "ArrowRight":
          event.preventDefault();
          setActiveIndex((i) => (i + 1) % itemCount);
          break;
        case "ArrowUp":
        case "ArrowLeft":
          event.preventDefault();
          setActiveIndex((i) => (i - 1 + itemCount) % itemCount);
          break;
        case "Home":
          event.preventDefault();
          setActiveIndex(0);
          break;
        case "End":
          event.preventDefault();
          setActiveIndex(itemCount - 1);
          break;
        default:
          break;
      }
    },
    [itemCount]
  );

  const getItemProps = (index) => ({
    tabIndex: index === activeIndex ? 0 : -1,
    onFocus: () => setActiveIndex(index),
  });

  return { activeIndex, onKeyDown, getItemProps };
}

5. ARIA live regions for dynamic content

When content changes without a page transition, for example an error message after a form submit or a toast after a successful action, a screen reader user does not automatically notice the change, because no focus shift occurs. ARIA live regions solve this problem: a container with aria-live="polite" is read aloud by the screen reader as soon as its content changes, without interrupting the user's current activity. For critical, immediately important messages, use aria-live="assertive", which interrupts the current announcement.

An important pitfall in an accessible component library: the live region container must already exist in the DOM on initial render, even if it starts empty. If the container is only inserted dynamically once the message is already known, many screen readers do not register the change, because they perceive the new node itself rather than its content change. The message must therefore be written into an already existing, empty live region container, rather than recreating the whole container along with the message.


// useAnnouncer.js — a reusable live region for toast-style announcements
import { useCallback, useRef } from "react";

export function useAnnouncer() {
  const regionRef = useRef(null);

  const announce = useCallback((message, priority = "polite") => {
    if (!regionRef.current) return;
    // Clear first, then set — forces screen readers to re-announce
    // even if the same message is sent twice in a row
    regionRef.current.textContent = "";
    regionRef.current.setAttribute("aria-live", priority);
    requestAnimationFrame(() => {
      regionRef.current.textContent = message;
    });
  }, []);

  // The region itself must exist in the DOM from the very first render
  const LiveRegion = () => (
    <div ref={regionRef} aria-live="polite" role="status" className="sr-only" />
  );

  return { announce, LiveRegion };
}

6. Building a focus trap for modals from scratch

A modal without a focus trap is one of the most common accessibility gaps in production applications: keyboard users can tab out of the visible dialog into elements behind it that are visually hidden but still technically focusable. An accessible component library must set focus to the first interactive element inside a modal when it opens, cycle it from the last to the first element and back when tabbing within the group, and reliably return it to the triggering element when it closes.

In addition, the rest of the page content must be made invisible to assistive technologies while the modal is open, typically with aria-hidden="true" on the sibling content of the modal root, or with the native inert attribute, which has recently gained broad browser support. Without this measure, a screen reader user can still reach content behind the modal despite it being open and becomes confused as a result, while sighted users do not see the background at all.


// useFocusTrap.js — minimal focus trap for a modal dialog
import { useEffect, useRef } from "react";

export function useFocusTrap(isOpen) {
  const containerRef = useRef(null);
  const previouslyFocused = useRef(null);

  useEffect(() => {
    if (!isOpen || !containerRef.current) return;

    previouslyFocused.current = document.activeElement;
    const focusable = containerRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
    first?.focus();

    function handleKeyDown(event) {
      if (event.key !== "Tab") return;
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    containerRef.current.addEventListener("keydown", handleKeyDown);
    return () => {
      containerRef.current?.removeEventListener("keydown", handleKeyDown);
      // Restore focus to whatever triggered the modal
      previouslyFocused.current?.focus();
    };
  }, [isOpen]);

  return containerRef;
}

7. Automated testing with axe-core

Automated testing finds a relevant but limited share of all accessibility problems, typically around thirty percent. Even so, integrating axe-core into every accessible component library pays off, because it reliably finds exactly the errors that are automatically checkable: missing alt text, insufficient color contrast, missing form labels, invalid ARIA attribute combinations. This class of errors creeps into growing codebases particularly easily and is reliably caught by an automated test in the CI pipeline before it reaches production.

Integration with React Testing Library is straightforward: jest-axe or the equivalent Vitest package renders a component and checks the resulting DOM against the axe-core rules. It is important to run these tests for every component of the accessible component library in several relevant states, not just the initial state, because many accessibility errors only become visible after an interaction, for example an opened dropdown without a correctly updated aria-expanded.


// Button.test.jsx — automated accessibility check with jest-axe
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Button } from "./Button";

expect.extend(toHaveNoViolations);

test("Button has no accessibility violations", async () => {
  const { container } = render(<Button>Submit form</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

test("Disabled button is announced correctly", async () => {
  const { container } = render(<Button disabled>Submit form</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

8. Screen reader testing beyond automated tools

Because automated testing only covers part of the relevant problems, a serious accessible component library also needs manual testing with real screen readers. NVDA with Firefox on Windows and VoiceOver with Safari on macOS together cover the large majority of practical cases. The test should be done exclusively with the keyboard, with eyes closed or the monitor turned off, to genuinely experience what a screen reader user perceives instead of relying on visual feedback.

A common outcome of such manual tests is discovering that a component passes all automated checks but produces confusing or redundant announcements in practice, for example duplicate role declarations or an incorrect announcement order for complex widgets. For an accessible component library that is used in production, manual screen reader testing should happen at least for every new complex component, not just once at the first release.

9. Custom library compared to Radix and React Aria

The following table compares building your own accessible component library with using established alternatives.

Approach Effort Control Accessibility maturity
Custom library High, ongoing Full Fully depends on your own team
Radix Primitives Low for standard cases Limited to the Radix API Very high, broadly tested
React Aria (Adobe) Medium, more low level control High, granular hooks Very high, years of research

Radix and React Aria already cover most standard widgets with excellent accessibility maturity and are the more pragmatic choice for the vast majority of projects. A completely custom accessible component library pays off mainly when specific requirements exist that neither library covers, or when a team needs full control and traceability over every line of interaction logic for regulatory reasons.

Mironsoft

React accessibility and WCAG conformance

Need an accessible component library for your product?

We build and audit React components with correct focus management, roving tabindex, live regions, and automated axe-core testing against WCAG 2.2.

Accessibility audit

Check existing components against WCAG 2.2

Component build

Focus management and ARIA built in from the start

Testing integration

axe-core in CI pipeline and manual screen reader testing

10. Summary

An accessible component library emerges from consistently applying a handful of core patterns: visible focus with :focus-visible, roving tabindex for composite widgets, ARIA live regions for dynamic announcements, and a reliable focus trap for modals. These patterns can be encapsulated as reusable hooks and reused in every new component instead of reinventing them every time.

Automated testing with axe-core catches a relevant but limited share of errors and belongs in every CI pipeline. Manual testing with real screen readers remains indispensable, because many problems only become visible in actual use. For most projects, a combination of established libraries such as Radix or React Aria for standard widgets and a custom accessible component library for domain specific cases is the most pragmatic path.

Accessible Component Library — The Essentials

Focus management

:focus-visible instead of outline none, focus order must match visual order.

Roving tabindex

Only one group element with tabIndex 0, arrow keys navigate within the group.

Live regions and focus trap

Live region container in the DOM from the start. Focus trap cycles and restores focus on close.

Testing

axe-core in CI plus manual screen reader testing with NVDA and VoiceOver.

11. FAQ: Accessible Component Library in React

1What sets an accessible library apart?
Focus management and ARIA are planned from the start, not a later patch.
2Why not remove outline none?
Without a focus ring keyboard users lose orientation. :focus-visible is the right replacement.
3What is roving tabindex?
Only one group element with tabIndex 0, arrow keys navigate within the group.
4When do I need a live region?
On content changes without a focus shift, for example error messages or toasts.
5Why must the container exist beforehand?
Otherwise screen readers only detect the new node, not the content change.
6What does a focus trap do?
Keeps focus trapped in the modal and restores it to the trigger on close.
7How much does automated testing find?
Around thirty percent, the rest needs manual screen reader testing.
8Which screen readers to test?
NVDA with Firefox and VoiceOver with Safari cover most practical cases.
9Radix or React Aria instead of custom?
For standard widgets usually yes, due to high accessibility maturity and broad testing.
10When to build your own?
For very specific requirements or when full control is regulatorily required.