Headless UI Patterns in React: Beyond Radix
AI generated
</>
{ }
React · Component Design · Accessibility
Headless UI Patterns in React
your own accessible components beyond Radix

Headless UI patterns consistently separate interaction logic from visual presentation, so teams can implement their own design without compromising keyboard control or ARIA semantics. Once you understand how custom hooks, compound components and state machines work together, many use cases no longer need an external headless library at all.

19 min read Custom Hooks · Compound Components · State Machines React 19 · TypeScript

1. What headless UI patterns actually solve

A headless UI pattern consistently separates a component's interaction logic, meaning keyboard control, focus management, ARIA attributes and state handling, from its visual presentation. Unlike a finished design system, a headless pattern does not ship CSS classes or a fixed look, only the logic plus the right props so you can render it yourself. The result is a component that fits into any design system without fighting foreign styles.

In practice, many teams first encounter headless UI patterns through libraries such as Radix or Tailwind Labs' Headless UI. That is a good starting point, but it is important to understand that there is no magic behind these libraries, only recurring headless UI patterns: custom hooks for state, compound components for composition, and sometimes state machines for complex flows. Whoever masters these building blocks can build their own leaner headless components whenever a ready made library brings too much weight or too little flexibility.

2. The core principle: separating logic and presentation

The core of every headless UI pattern is the radical separation between what a component does and how it looks. A dropdown must know whether it is open, which item has focus, and how it reacts to arrow keys. It does not need to know whether the background is white or dark blue. In React, this separation is achieved through three complementary techniques: custom hooks encapsulate state and behavior, compound components share that state across multiple child components via context, and render props or the children as function technique give the caller full control over the markup.

The benefit of these headless UI patterns shows especially in larger organizations with multiple product teams. Every team can reuse the same interaction logic while still maintaining its own visual appearance that matches its respective brand. Without this separation, each team would either have to reimplement the whole logic or be forced to adopt a foreign visual scaffold that does not match its own brand.

3. Custom hooks as the headless foundation

The simplest entry point into headless UI patterns is a custom hook that encapsulates a component's entire state and returns no JSX at all. A useDisclosure hook, for example, only manages a boolean plus functions to open, close and toggle. A useListNavigation hook manages the active index of a list and reacts to keyboard events. These hooks are entirely UI agnostic: they work identically whether you build a modal, a dropdown, or a toast.

What matters in this headless UI pattern is that the hook returns all necessary ARIA props as a ready made object that gets spread onto the respective element. This means the caller does not need to know which aria-* attributes are required in detail, but gets them set correctly automatically. This significantly reduces accessibility bugs, because the ARIA logic is maintained in exactly one place inside the hook instead of being copied into every component again.


// useDisclosure.js — headless state hook, no rendering logic at all
import { useCallback, useId, useState } from "react";

export function useDisclosure(defaultOpen = false) {
  const [isOpen, setIsOpen] = useState(defaultOpen);
  const contentId = useId();

  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);
  const toggle = useCallback(() => setIsOpen((prev) => !prev), []);

  // Ready-to-spread prop bundles — the caller renders, the hook decides
  const triggerProps = {
    "aria-expanded": isOpen,
    "aria-controls": contentId,
    onClick: toggle,
  };

  const contentProps = {
    id: contentId,
    hidden: !isOpen,
    role: "region",
  };

  return { isOpen, open, close, toggle, triggerProps, contentProps };
}

// Usage — the hook has zero opinion about markup or styling
function Accordion({ title, children }) {
  const { triggerProps, contentProps } = useDisclosure();
  return (
    <div className="my-own-styles">
      <button {...triggerProps}>{title}</button>
      <div {...contentProps}>{children}</div>
    </div>
  );
}

4. Compound components without an external library

As soon as a component consists of several related parts, for example tabs with a tab list and panels, a single hook is no longer enough. This is where the headless UI pattern of compound components comes in: a parent component holds shared state in a context, and several child components consume that context to behave accordingly. The caller decides entirely on their own how many tabs exist and in what order they appear in the markup, while the parent component only handles coordination.

The decisive difference compared to a monolithic component with a huge props list is flexibility. Instead of <Tabs items={[...]} renderTab={...} /> with dozens of configuration options, you simply write JSX that mirrors the structure directly. This headless UI pattern is also why Radix and Headless UI rely almost entirely on compound components: it scales with the complexity of the UI without the API exploding.


// Tabs.jsx — compound component built from scratch, no external library
import { createContext, useContext, useId, useState } from "react";

const TabsContext = createContext(null);

function Tabs({ defaultValue, children }) {
  const [active, setActive] = useState(defaultValue);
  const groupId = useId();
  return (
    <TabsContext.Provider value={{ active, setActive, groupId }}>
      <div className="tabs-root">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }) {
  return <div role="tablist">{children}</div>;
}

function Tab({ value, children }) {
  const { active, setActive, groupId } = useContext(TabsContext);
  const isActive = active === value;
  return (
    <button
      role="tab"
      id={`${groupId}-tab-${value}`}
      aria-selected={isActive}
      aria-controls={`${groupId}-panel-${value}`}
      tabIndex={isActive ? 0 : -1}
      onClick={() => setActive(value)}
    >
      {children}
    </button>
  );
}

function Panel({ value, children }) {
  const { active, groupId } = useContext(TabsContext);
  if (active !== value) return null;
  return (
    <div role="tabpanel" id={`${groupId}-panel-${value}`} tabIndex={0}>
      {children}
    </div>
  );
}

Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = Panel;
export { Tabs };

// Usage — the shape of the JSX mirrors the shape of the UI
// <Tabs defaultValue="overview">
//   <Tabs.List>
//     <Tabs.Tab value="overview">Overview</Tabs.Tab>
//     <Tabs.Tab value="details">Details</Tabs.Tab>
//   </Tabs.List>
//   <Tabs.Panel value="overview">...</Tabs.Panel>
//   <Tabs.Panel value="details">...</Tabs.Panel>
// </Tabs>

5. State machines for complex interaction logic

Simple boolean state is enough for accordions and toggles, but as soon as a component has several mutually exclusive states, for example a combobox with the states closed, open, filtering and selected, a plain useState call quickly becomes unmanageable. This is exactly where another headless UI pattern shows its strength: an explicit state machine, either hand written with useReducer or built with a library like XState. Every state clearly defines which events are allowed within it and which follow up state they lead to.

The benefit of this headless UI pattern lies in making invalid state combinations impossible. A reducer with clearly defined transitions cannot end up in a state where "closed" and "option selected" hold at the same time, which easily happens with several independent booleans. For headless components with many interaction paths, such as date pickers or multi selects, a state machine is often the only way to keep complexity manageable long term.


// useComboboxMachine.js — explicit states instead of scattered booleans
import { useReducer } from "react";

const initialState = { status: "closed", query: "", activeIndex: -1 };

function reducer(state, action) {
  switch (action.type) {
    case "OPEN":
      return { ...state, status: "open", activeIndex: 0 };
    case "TYPE":
      return { ...state, status: "filtering", query: action.query, activeIndex: 0 };
    case "MOVE":
      return { ...state, activeIndex: action.index };
    case "SELECT":
      return { status: "closed", query: action.label, activeIndex: -1 };
    case "CLOSE":
      return { ...state, status: "closed" };
    default:
      return state;
  }
}

export function useComboboxMachine() {
  const [state, dispatch] = useReducer(reducer, initialState);
  // Every transition is explicit — no impossible combined states possible
  return { state, dispatch };
}

6. A headless primitive from scratch: a listbox example

To show how the previous building blocks come together into a complete headless UI pattern, a concrete example is worth walking through: a listbox, the kind that replaces a select element when you need more control over presentation. The useListbox hook manages the active index, reacts to arrow up, arrow down, home, end and enter, and returns ready made props for the list and each item. The visual component itself no longer contains a single line of interaction logic, only markup and classes.

This pattern transfers to arbitrarily more complex components: a date picker additionally needs date calculations, a multi select needs an array instead of a single value, but the underlying headless UI pattern of a state hook, keyboard handling and ready made props stays identical. Whoever internalizes this one example can transfer it to most interactive components without starting from zero every time.


// useListbox.js — full keyboard-driven headless listbox primitive
import { useCallback, useState } from "react";

export function useListbox(items) {
  const [activeIndex, setActiveIndex] = useState(0);
  const [selected, setSelected] = useState(null);

  const onKeyDown = useCallback(
    (event) => {
      switch (event.key) {
        case "ArrowDown":
          event.preventDefault();
          setActiveIndex((i) => Math.min(i + 1, items.length - 1));
          break;
        case "ArrowUp":
          event.preventDefault();
          setActiveIndex((i) => Math.max(i - 1, 0));
          break;
        case "Home":
          event.preventDefault();
          setActiveIndex(0);
          break;
        case "End":
          event.preventDefault();
          setActiveIndex(items.length - 1);
          break;
        case "Enter":
        case " ":
          event.preventDefault();
          setSelected(items[activeIndex]);
          break;
        default:
          break;
      }
    },
    [activeIndex, items]
  );

  const getListProps = () => ({ role: "listbox", tabIndex: 0, onKeyDown });
  const getItemProps = (index) => ({
    role: "option",
    "aria-selected": items[index] === selected,
    "data-active": index === activeIndex,
    onClick: () => setSelected(items[index]),
  });

  return { activeIndex, selected, getListProps, getItemProps };
}

7. Accessibility in headless components

Accessibility is not an afterthought bolted onto headless UI patterns, it is their actual reason for existing. The most important principle is roving tabindex: within a related group such as a listbox or toolbar, only one element ever has tabIndex={0}, all others have tabIndex={-1}. When the active element changes via arrow key, the tabindex shifts and focus is set programmatically with element.focus(). This makes the browser's tab focus jump directly into the group and back out, instead of stopping at every single item.

Just as important is focus restoration: when a modal or popover is closed via escape, focus must reliably return to the element that opened it. A headless UI pattern for this is to store a reference to the triggering element in a ref before the dialog opens, and to call previouslyFocusedElement.current.focus() on close. Combined with correct aria-* attributes and a focus trap inside modals, this results in a component that works just as well with keyboard and screen reader as it does with a mouse.

8. When to use Radix, when to build your own patterns

Radix and Headless UI are excellently tested, production ready implementations of exactly the headless UI patterns explained in this article. For standard components like dialog, popover, dropdown menu or tooltip, there is a strong case for using one of these libraries instead of solving edge cases around focus trapping and portal rendering yourself. The effort of building a dialog primitive with correct focus trap, escape handling and scroll lock from scratch and testing it across all browsers is real and easily underestimated.

Your own headless UI patterns pay off, on the other hand, when a component encodes very specific domain knowledge that no generic library offers, when the bundle budget is particularly tight, or when a team already has its own state management architecture that a foreign library does not fit into cleanly. In practice, a mix is typical: Radix for standard primitives, your own hooks for domain specific components such as a product configurator or a pricing table with unusual interaction logic.

9. Headless UI patterns compared

The following table compares the most important approaches to headless UI patterns and shows when which approach is the better choice.

Approach Strength Weakness Typical use
Custom hook Minimal, easy to test Not enough with multiple child components Toggle, disclosure, simple counter
Compound components Flexible JSX structure, no props sprawl Needs context, more boilerplate Tabs, accordion, steps
State machine Impossible states excluded Higher entry barrier Combobox, multi step form
Radix / Headless UI Tested, accessible, portal included Extra dependency, less control Dialog, popover, dropdown menu
Custom primitive Full control, smallest bundle Maintenance burden fully on the team Domain specific components

The choice between these headless UI patterns is rarely binary. Most production ready design systems combine several approaches within the same project, depending on how complex and how specific the respective component is. What matters is making the decision consciously and not always picking the same approach out of habit, regardless of the component's actual complexity.

Mironsoft

React component design and accessibility

Need your own headless components for your design system?

We design headless UI patterns that fit your existing architecture, from custom hooks to fully accessible compound components with keyboard control and ARIA semantics.

Architecture review

Check existing components for headless readiness

Component build

Custom hooks, compound components and state machines

Accessibility audit

Keyboard control and ARIA checked against WCAG

10. Summary

Headless UI patterns solve a recurring problem: interaction logic should be reusable without forcing a specific visual appearance. Custom hooks encapsulate simple state, compound components coordinate several related child components via context, and state machines exclude impossible state combinations in complex interactions. Accessibility, especially roving tabindex and focus restoration, is not an optional extra here, it is the actual reason why the extra effort pays off.

Radix and Headless UI remain the pragmatic choice for standard components, because they already implement these headless UI patterns in a well tested way. For domain specific components where no ready made library fits, or when the bundle budget is tight, it is worth looking at the underlying patterns themselves. Whoever masters custom hooks, compound components and state machines is no longer dependent on a single library and can make well founded decisions about which approach fits which component.

Headless UI Patterns in React — The Essentials

Core principle

Separate logic from presentation. The hook decides what happens, the caller decides how it looks.

Building blocks

Custom hooks for state, compound components for composition, state machines for complex flows.

Accessibility

Roving tabindex and focus restoration are mandatory, not optional. ARIA props belong in the hook.

Radix vs. custom

Use Radix for standard primitives, build custom patterns for domain specific components.

11. FAQ: Headless UI Patterns in React

1What exactly is a headless UI pattern?
Logic, state and ARIA are provided, markup and styling are entirely up to the caller.
2Do I always need Radix?
No, custom hooks suffice for simple or domain specific cases. Radix pays off for complex primitives with portal and focus trap.
3When compound components over a hook?
As soon as multiple child components need shared state, for example a tab list and panels in tabs.
4What is roving tabindex?
Only one element in the group has tabIndex 0, the rest minus 1. Arrow keys shift the tabindex within the group.
5Why a state machine instead of useState?
Independent booleans can end up in invalid combinations. A reducer with clear transitions excludes this.
6How does focus restoration work?
Store the triggering element in a ref before opening, restore focus with focus() on close.
7Only relevant for design systems?
No, single apps benefit too, as soon as logic is reused across components with different appearances.
8More maintenance than Radix?
Usually yes. Radix is maintained by a dedicated team, custom patterns pay off for specific needs or a tight bundle.
9How do I test a headless hook?
With renderHook from React Testing Library, isolated, without rendering a visual component.
10What if ARIA attributes are missing?
Screen readers report the wrong or no state at all. ARIA belongs in the hook, not the calling component.