Skip to content
← Back to blog

React Aria and the Compound Component Pattern: Building Accessible UI Without a Design System

ReactAccessibilityReact AriaCompound Components

When your design system is being migrated or doesn't exist yet, you still need to ship accessible components. You can't wait for the perfect abstraction — but you also can't ship inaccessible garbage and promise to fix it later.

React Aria solves this. It gives you accessibility primitives — keyboard navigation, focus management, ARIA attributes — without imposing any visual style. Pair it with the compound component pattern and you get composable, accessible building blocks that work across teams.

Why React Aria?

Most component libraries bundle accessibility with styling. Radix UI gives you unstyled primitives, but they're still opinionated about DOM structure. React Aria takes a different approach: it gives you hooks and behaviour, not components.

import { useToggleButton } from 'react-aria';
import { useToggleState } from 'react-stately';
 
function ToggleButton(props: ToggleButtonProps) {
  const ref = useRef<HTMLButtonElement>(null);
  const state = useToggleState(props);
  const { buttonProps } = useToggleButton(props, state, ref);
 
  return (
    <button {...buttonProps} ref={ref}>
      {props.children}
    </button>
  );
}

You own the DOM. You own the styles. React Aria handles the behaviour: keyboard events, ARIA attributes, focus management, and screen reader announcements.

The Compound Component Pattern

Compound components let you build flexible APIs where related components share implicit state. Think of <select> and <option> — they're useless alone but powerful together.

Here's a real example: a filter chip group that supports three interaction modes.

The Problem

You need a chip group component that works as:

  • Presentational — read-only display of active filters
  • Single-select — radio-button behaviour (one active at a time)
  • Multi-select — checkbox behaviour (multiple active)

A single monolithic component with a mode prop gets messy fast. Compound components keep each concern isolated.

The Implementation

Start with a context that holds shared state:

import { useListState } from 'react-stately';
import { useListBox, useOption } from 'react-aria';
 
interface ChipGroupContextValue {
  state: ListState<unknown>;
  listBoxProps: HTMLAttributes<HTMLElement>;
}
 
const ChipGroupContext = createContext<ChipGroupContextValue | null>(null);

The parent component manages selection state via React Stately:

function ChipGroup({
  children,
  selectionMode = 'none',
  ...props
}: ChipGroupProps) {
  const state = useListState({
    ...props,
    selectionMode,
  });
 
  const ref = useRef<HTMLDivElement>(null);
  const { listBoxProps } = useListBox(
    { ...props, selectionMode },
    state,
    ref
  );
 
  return (
    <ChipGroupContext.Provider value={{ state, listBoxProps }}>
      <div {...listBoxProps} ref={ref} role="listbox">
        {[...state.collection].map((item) => (
          <Chip key={item.key} item={item} />
        ))}
      </div>
    </ChipGroupContext.Provider>
  );
}

Each chip consumes the context and gets its behaviour from useOption:

function Chip({ item }: { item: Node<unknown> }) {
  const { state } = useContext(ChipGroupContext)!;
  const ref = useRef<HTMLDivElement>(null);
  const { optionProps, isSelected, isFocused } = useOption(
    { key: item.key },
    state,
    ref
  );
 
  return (
    <div
      {...optionProps}
      ref={ref}
      className={clsx(
        'chip',
        isSelected && 'chip--selected',
        isFocused && 'chip--focused'
      )}
    >
      {item.rendered}
    </div>
  );
}

What You Get for Free

With this setup, React Aria automatically provides:

  • Keyboard navigation — arrow keys move between chips, Space/Enter toggles selection
  • ARIA attributesrole="listbox", role="option", aria-selected, aria-checked
  • Focus management — focus ring follows keyboard navigation, wraps at boundaries
  • Selection announcements — screen readers announce "selected" / "deselected" state changes
  • Type-ahead — typing a letter jumps to matching chips

You wrote zero accessibility code. It's all in the hooks.

Consistent ID Management

One gotcha with compound components: IDs. When multiple instances of the same component exist on a page, you need unique IDs for aria-labelledby, aria-describedby, and other associations.

React Aria's useId hook handles this:

import { useId } from 'react-aria';
 
function ChipGroup({ label, ...props }: ChipGroupProps) {
  const labelId = useId();
 
  return (
    <div aria-labelledby={labelId}>
      <span id={labelId}>{label}</span>
      {/* chips */}
    </div>
  );
}

Every instance gets a globally unique ID. No manual id="filter-group-1" strings.

When to Reach for This Pattern

This approach shines when:

  • Your design system is being migrated and you can't wait for stable shared components
  • Different teams need the same behaviour with different visuals
  • You need interaction variants (read-only, single-select, multi-select) without prop explosion
  • Accessibility is a hard requirement, not a nice-to-have

It's overkill for one-off components. But for anything that'll be reused across multiple surfaces or teams, the investment in compound components + React Aria pays back immediately — fewer accessibility bugs, less duplicated behaviour code, and less time spent in axe-core remediation.

Key Takeaways

  1. React Aria gives you behaviour, not components. You keep full control over DOM and styling.
  2. Compound components + context = flexible APIs without prop drilling. Each piece is independently styled but shares selection/focus state.
  3. Interaction variants (none/single/multi) are a state machine problem. React Stately handles the machine; you handle the visuals.
  4. IDs are a foot-gun in compound components. Use useId from React Aria, not manual strings.
  5. Invest in primitives, not finished components. When your design system is in flux, primitives survive the redesign.