Headless/Unstyled React Component Library Design

Frontend Engineering intermediate Building with AI ~20 min read

Overview

A headless component library separates behavior from presentation. The library ships the keyboard handling, ARIA attributes, focus management, and state transitions—everything that makes a component correct and accessible—while deliberately shipping zero CSS. Consumers own 100% of the visual design. This is the approach taken by Radix UI Primitives, Headless UI (from Tailwind Labs), and React Aria (from Adobe). Their common insight: styling is opinionated and design-system-specific, but accessibility semantics and interaction behavior are universal and well-specified by the WAI-ARIA standards.

The alternative—a styled component library like Material UI or Ant Design—forces teams into a visual contract they did not choose. Overriding those styles requires fighting specificity wars, wrapping with extra DOM nodes, or forking the library outright. Every upgrade risks visual regressions from upstream style changes. Headless libraries avoid all of this: behavior upgrades are safe because they cannot affect appearance; design changes require no library upgrade at all.

Building your own headless library (rather than consuming an existing one) is appropriate when you need very specific state semantics, when you are building a cross-platform design system that renders to multiple targets (web, React Native, email), or when you want to understand the patterns deeply enough to debug or extend Radix/Headless UI effectively. The core patterns—compound components, render props, and context-based implicit state sharing—apply equally to building or consuming headless primitives.

Core Concepts

Headless Philosophy: Behavior Without Appearance

A headless component manages state, wires up event handlers, and computes the props that a DOM element needs to be accessible—but it never adds className, inline style, or DOM structure beyond what ARIA semantics require. The consuming developer renders whatever markup they like; the headless layer hands them the props to spread. Zero default CSS means no stylesheet to import, no CSS variable to override, and no cascade to fight.

Compound Components Pattern

Compound components model a composite widget as a family of sub-components that communicate through shared context rather than explicit prop drilling. A <Select> root manages open/close state and the selected value; its <Select.Trigger>, <Select.Content>, and <Select.Item> children consume that context automatically. The API reads like HTML—declarative, composable, and immune to the prop explosion that plagues monolithic single-component designs.

Render Props and Children as Function

When a component needs to expose internal state to the caller’s render output, a render prop solves the problem without adding wrapper DOM nodes. The pattern children={(state) => <div>...</div>} (children-as-function) is the most common variant. The headless component calls children with its current state—{ isOpen, isSelected, isDisabled }—so the caller can conditionally apply classes, swap icons, or animate elements based on state without any internal coupling.

Context-Based Implicit State Sharing

React Context is the mechanism that makes compound components work without prop drilling. The root component creates a context, stores its state in it, and provides it to the tree. Sub-components read only what they need from that context. This implicit channel keeps sub-component APIs minimal (a <Select.Item value="x"> needs no onSelect prop—it reads the dispatch from context). The critical discipline is keeping context values stable: memoize objects and callbacks to prevent unnecessary re-renders of every subscriber on every keystroke.

Data Attribute Styling Hooks

CSS classes are fragile styling hooks—they clash with design systems, they require BEM discipline, and they cannot be targeted by attribute selectors without extra CSS. Headless libraries instead set data-* attributes that reflect current state: data-state="open", data-highlighted, data-disabled. Consumers target these with CSS attribute selectors ([data-state="open"] { ... }) or Tailwind’s data- variant syntax (data-[state=open]:rotate-180), giving full styling control with zero class name conflicts.

Building a Headless Select Component

Context and Root State

The Select root owns all state: whether the dropdown is open, which value is currently selected, and which item is highlighted for keyboard navigation. It exposes this state through context so every sub-component can read and update it without prop threading.

// select/SelectContext.tsx
import React, { createContext, useContext, useId, useReducer, useCallback } from "react";

interface SelectState {
  isOpen: boolean;
  value: string | null;
  highlightedIndex: number;
  items: string[];
}

type SelectAction =
  | { type: "OPEN" }
  | { type: "CLOSE" }
  | { type: "SELECT"; value: string }
  | { type: "HIGHLIGHT"; index: number }
  | { type: "REGISTER_ITEM"; value: string }
  | { type: "UNREGISTER_ITEM"; value: string };

function selectReducer(state: SelectState, action: SelectAction): SelectState {
  switch (action.type) {
    case "OPEN":
      return { ...state, isOpen: true };
    case "CLOSE":
      return { ...state, isOpen: false, highlightedIndex: -1 };
    case "SELECT":
      return { ...state, value: action.value, isOpen: false, highlightedIndex: -1 };
    case "HIGHLIGHT":
      return { ...state, highlightedIndex: action.index };
    case "REGISTER_ITEM":
      return state.items.includes(action.value)
        ? state
        : { ...state, items: [...state.items, action.value] };
    case "UNREGISTER_ITEM":
      return { ...state, items: state.items.filter((v) => v !== action.value) };
    default:
      return state;
  }
}

interface SelectContextValue {
  state: SelectState;
  dispatch: React.Dispatch<SelectAction>;
  triggerId: string;
  listboxId: string;
}

const SelectContext = createContext<SelectContextValue | null>(null);

export function useSelectContext(): SelectContextValue {
  const ctx = useContext(SelectContext);
  if (!ctx) throw new Error("useSelectContext must be used within <Select>");
  return ctx;
}

// ---- Root component ----

interface SelectProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  children: React.ReactNode;
}

export function Select({ value, defaultValue, onValueChange, children }: SelectProps) {
  const triggerId = useId();
  const listboxId = useId();

  const [state, dispatch] = useReducer(selectReducer, {
    isOpen: false,
    value: value ?? defaultValue ?? null,
    highlightedIndex: -1,
    items: [],
  });

  // Sync controlled value
  React.useEffect(() => {
    if (value !== undefined && value !== state.value) {
      dispatch({ type: "SELECT", value });
    }
  }, [value]);

  // Notify parent on selection
  const wrappedDispatch = useCallback(
    (action: SelectAction) => {
      dispatch(action);
      if (action.type === "SELECT") {
        onValueChange?.(action.value);
      }
    },
    [onValueChange]
  );

  const ctx = React.useMemo(
    () => ({ state, dispatch: wrappedDispatch, triggerId, listboxId }),
    [state, wrappedDispatch, triggerId, listboxId]
  );

  return (
    <SelectContext.Provider value={ctx}>
      {children}
    </SelectContext.Provider>
  );
}

Trigger Sub-Component

The trigger is the button that opens and closes the dropdown. It receives no styling props—it spreads ARIA attributes and event handlers onto whatever element the caller provides via the asChild pattern, or falls back to a plain &lt;button>. The data-state attribute is the styling hook.

// select/SelectTrigger.tsx
import { useSelectContext } from "./SelectContext";

interface SelectTriggerProps {
  children: React.ReactNode | ((state: { isOpen: boolean; value: string | null }) => React.ReactNode);
  className?: string;
  asChild?: boolean;
}

export function SelectTrigger({ children, className, asChild }: SelectTriggerProps) {
  const { state, dispatch, triggerId, listboxId } = useSelectContext();

  function handleKeyDown(e: React.KeyboardEvent) {
    switch (e.key) {
      case "Enter":
      case " ":
      case "ArrowDown":
        e.preventDefault();
        dispatch({ type: "OPEN" });
        break;
      case "Escape":
        dispatch({ type: "CLOSE" });
        break;
    }
  }

  const triggerProps = {
    id: triggerId,
    role: "combobox" as const,
    "aria-expanded": state.isOpen,
    "aria-haspopup": "listbox" as const,
    "aria-controls": listboxId,
    "data-state": state.isOpen ? "open" : "closed",
    onClick: () => dispatch({ type: state.isOpen ? "CLOSE" : "OPEN" }),
    onKeyDown: handleKeyDown,
    className,
  };

  // Render-prop variant: expose internal state to caller
  const content =
    typeof children === "function"
      ? children({ isOpen: state.isOpen, value: state.value })
      : children;

  return <button {...triggerProps}>{content}</button>;
}

Content and Item Sub-Components

The listbox content renders only when open, manages keyboard navigation across items, and closes on outside click. Each item registers itself in context on mount so the root can build the navigable list without manual index tracking.

// select/SelectContent.tsx
import { useEffect, useRef } from "react";
import { useSelectContext } from "./SelectContext";

interface SelectContentProps {
  children: React.ReactNode;
  className?: string;
}

export function SelectContent({ children, className }: SelectContentProps) {
  const { state, dispatch, listboxId, triggerId } = useSelectContext();
  const ref = useRef<HTMLUListElement>(null);

  // Close on outside click
  useEffect(() => {
    if (!state.isOpen) return;
    function handler(e: MouseEvent) {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        dispatch({ type: "CLOSE" });
      }
    }
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [state.isOpen, dispatch]);

  // Keyboard navigation within list
  useEffect(() => {
    if (!state.isOpen) return;
    function handler(e: KeyboardEvent) {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        dispatch({
          type: "HIGHLIGHT",
          index: Math.min(state.highlightedIndex + 1, state.items.length - 1),
        });
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        dispatch({
          type: "HIGHLIGHT",
          index: Math.max(state.highlightedIndex - 1, 0),
        });
      } else if (e.key === "Enter" && state.highlightedIndex >= 0) {
        e.preventDefault();
        dispatch({ type: "SELECT", value: state.items[state.highlightedIndex] });
      } else if (e.key === "Escape") {
        dispatch({ type: "CLOSE" });
      }
    }
    document.addEventListener("keydown", handler);
    return () => document.removeEventListener("keydown", handler);
  }, [state.isOpen, state.highlightedIndex, state.items, dispatch]);

  if (!state.isOpen) return null;

  return (
    <ul
      ref={ref}
      id={listboxId}
      role="listbox"
      aria-labelledby={triggerId}
      data-state="open"
      className={className}
    >
      {children}
    </ul>
  );
}

// select/SelectItem.tsx
import { useEffect } from "react";
import { useSelectContext } from "./SelectContext";

interface SelectItemProps {
  value: string;
  disabled?: boolean;
  children: React.ReactNode | ((state: { isSelected: boolean; isHighlighted: boolean; isDisabled: boolean }) => React.ReactNode);
  className?: string;
}

export function SelectItem({ value, disabled = false, children, className }: SelectItemProps) {
  const { state, dispatch } = useSelectContext();

  const index = state.items.indexOf(value);
  const isSelected = state.value === value;
  const isHighlighted = state.highlightedIndex === index;

  useEffect(() => {
    dispatch({ type: "REGISTER_ITEM", value });
    return () => dispatch({ type: "UNREGISTER_ITEM", value });
  }, [value, dispatch]);

  const content =
    typeof children === "function"
      ? children({ isSelected, isHighlighted, isDisabled: disabled })
      : children;

  return (
    <li
      role="option"
      aria-selected={isSelected}
      aria-disabled={disabled}
      data-selected={isSelected ? "" : undefined}
      data-highlighted={isHighlighted ? "" : undefined}
      data-disabled={disabled ? "" : undefined}
      className={className}
      onClick={() => !disabled && dispatch({ type: "SELECT", value })}
      onMouseEnter={() => !disabled && dispatch({ type: "HIGHLIGHT", index })}
    >
      {content}
    </li>
  );
}

Composing the Headless Select

With the primitives in place, consumers assemble their own styled Select from scratch. The headless layer handles state and ARIA; the CSS is entirely the consumer’s responsibility. The data-* attributes are the only styling contract between the library and the consumer.

// Usage: consumer brings all their own styles
import { Select, SelectTrigger, SelectContent, SelectItem } from "@/components/select";

function LanguagePicker() {
  return (
    <Select defaultValue="typescript" onValueChange={(v) => console.log("picked:", v)}>
      <SelectTrigger className="lang-trigger">
        {({ value, isOpen }) => (
          <>
            <span>{value ?? "Select language"}</span>
            {/* Chevron rotates via data-state on the parent trigger */}
            <svg
              className="lang-trigger__chevron"
              aria-hidden="true"
              viewBox="0 0 24 24"
              width="16"
              height="16"
            >
              <path d="M6 9l6 6 6-6" />
            </svg>
          </>
        )}
      </SelectTrigger>

      <SelectContent className="lang-listbox">
        {["typescript", "javascript", "rust", "go"].map((lang) => (
          <SelectItem key={lang} value={lang} className="lang-option">
            {({ isSelected, isHighlighted }) => (
              <>
                {isSelected && <span aria-hidden="true">✓</span>}
                {lang}
              </>
            )}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

/*
  Consumer CSS (plain CSS shown, but Tailwind data-[] variants work equally well):

  .lang-trigger {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
    border: 1px solid #ccc;
    border-radius: 6px;
    background: white;
    cursor: pointer;
  }

  .lang-trigger[data-state="open"] .lang-trigger__chevron {
    transform: rotate(180deg);
  }

  .lang-listbox {
    position: absolute;
    list-style: none;
    padding: 4px;
    margin: 4px 0 0;
    border: 1px solid #ccc;
    border-radius: 6px;
    background: white;
    box-shadow: 0 4px 12px rgba(0,0,0,.1);
  }

  .lang-option[data-highlighted] {
    background: #f0f0ff;
  }

  .lang-option[data-selected] {
    font-weight: 600;
    color: #6c63ff;
  }

  .lang-option[data-disabled] {
    opacity: 0.4;
    cursor: not-allowed;
  }
*/

Comparison with Radix UI and Headless UI

Radix UI Primitives and Headless UI are production-grade implementations of the same philosophy. Understanding their design decisions reveals the tradeoffs in rolling your own.

Radix UI Primitives

Radix takes the compound component pattern to its logical extreme. Every primitive (Select, Dialog, Tooltip, etc.) is a fully WAI-ARIA-compliant implementation tested across browsers and screen readers. Radix introduces the asChild prop as an alternative to render props: &lt;Select.Trigger asChild>&lt;MyButton />&lt;/Select.Trigger> merges the trigger’s event handlers and ARIA props into MyButton via the Slot primitive (which uses React.cloneElement). This avoids a wrapper DOM node entirely—the only element in the DOM is MyButton’s own element, with Radix’s props merged in. Radix also handles portal-based rendering for popovers and dialogs (using ReactDOM.createPortal to escape stacking contexts), focus trapping, and scroll locking—none of which you get for free in a minimal implementation.

Headless UI (Tailwind Labs)

Headless UI is tighter in scope than Radix, focusing on a smaller set of primitives optimized for Tailwind CSS consumers. Its distinguishing feature is first-class support for Tailwind’s data-[state]: variant syntax—components set data-open, data-active, data-checked attributes explicitly so Tailwind users can write data-[open]:opacity-100 without any configuration. Headless UI also ships a Transition component that applies enter/leave CSS classes based on data-* state, bridging the gap between CSS transitions and JavaScript-controlled visibility.

When to Roll Your Own

For most applications, use Radix or Headless UI rather than building from scratch—the accessibility testing work alone is months of effort. Build your own headless primitives when: you need a state machine that neither library models (e.g., a multi-step wizard where each step is a compound component family); you are targeting a non-DOM environment like React Native or a canvas-based renderer; or you need very fine-grained control over context granularity to avoid unnecessary re-renders in a performance-critical widget.

The asChild Pattern Versus Render Props

Both patterns solve the “I want the consumer to control the rendered element” problem. Render props require the consumer to write a function; asChild lets them just nest a component. asChild is ergonomically cleaner in JSX, but it uses React.cloneElement (or the more robust Slot implementation from Radix), which has subtleties: it merges refs, merges className strings, and chains event handlers. Render props are more explicit—the consumer sees exactly what props are being set—which makes them better for documentation and debugging in a library you are building and maintaining yourself.

Building a Headless Disclosure (Accordion Item)

A Disclosure is simpler than a Select and demonstrates the render-prop and data-* patterns without the keyboard navigation complexity. It is the pattern behind accordion items, FAQ entries, and collapsible sidebars.

// disclosure/Disclosure.tsx
import React, { createContext, useContext, useState, useId, useCallback } from "react";

interface DisclosureContextValue {
  isOpen: boolean;
  toggle: () => void;
  triggerId: string;
  panelId: string;
}

const DisclosureContext = createContext<DisclosureContextValue | null>(null);

function useDisclosure(): DisclosureContextValue {
  const ctx = useContext(DisclosureContext);
  if (!ctx) throw new Error("Must be used within <Disclosure>");
  return ctx;
}

// ---- Root ----

interface DisclosureProps {
  defaultOpen?: boolean;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: React.ReactNode | ((state: { isOpen: boolean }) => React.ReactNode);
}

export function Disclosure({ defaultOpen = false, open, onOpenChange, children }: DisclosureProps) {
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const triggerId = useId();
  const panelId = useId();

  const isOpen = open !== undefined ? open : internalOpen;

  const toggle = useCallback(() => {
    const next = !isOpen;
    setInternalOpen(next);
    onOpenChange?.(next);
  }, [isOpen, onOpenChange]);

  const ctx: DisclosureContextValue = { isOpen, toggle, triggerId, panelId };

  const content = typeof children === "function" ? children({ isOpen }) : children;

  return (
    <DisclosureContext.Provider value={ctx}>
      {content}
    </DisclosureContext.Provider>
  );
}

// ---- Trigger ----

interface DisclosureTriggerProps {
  children: React.ReactNode | ((state: { isOpen: boolean }) => React.ReactNode);
  className?: string;
}

export function DisclosureTrigger({ children, className }: DisclosureTriggerProps) {
  const { isOpen, toggle, triggerId, panelId } = useDisclosure();

  const content = typeof children === "function" ? children({ isOpen }) : children;

  return (
    <button
      id={triggerId}
      aria-expanded={isOpen}
      aria-controls={panelId}
      data-state={isOpen ? "open" : "closed"}
      onClick={toggle}
      className={className}
    >
      {content}
    </button>
  );
}

// ---- Panel ----

interface DisclosurePanelProps {
  children: React.ReactNode;
  className?: string;
  /**
   * If true, the panel is always in the DOM (hidden with aria-hidden).
   * If false (default), the panel is removed from the DOM when closed.
   * Keep-in-DOM is necessary for CSS transitions.
   */
  keepMounted?: boolean;
}

export function DisclosurePanel({ children, className, keepMounted = false }: DisclosurePanelProps) {
  const { isOpen, panelId, triggerId } = useDisclosure();

  if (!keepMounted && !isOpen) return null;

  return (
    <div
      id={panelId}
      role="region"
      aria-labelledby={triggerId}
      aria-hidden={!isOpen}
      data-state={isOpen ? "open" : "closed"}
      hidden={keepMounted ? !isOpen : undefined}
      className={className}
    >
      {children}
    </div>
  );
}

// ---- Usage ----

function FAQItem({ question, answer }: { question: string; answer: string }) {
  return (
    <Disclosure>
      <DisclosureTrigger className="faq-trigger">
        {({ isOpen }) => (
          <>
            <span>{question}</span>
            <svg
              aria-hidden="true"
              className="faq-icon"
              style={{ transform: isOpen ? "rotate(180deg)" : "rotate(0deg)" }}
              viewBox="0 0 24 24"
              width="20"
              height="20"
            >
              <path d="M6 9l6 6 6-6" />
            </svg>
          </>
        )}
      </DisclosureTrigger>
      <DisclosurePanel keepMounted className="faq-panel">
        <p>{answer}</p>
      </DisclosurePanel>
    </Disclosure>
  );
}

/*
  CSS using data-state attribute selectors:

  .faq-trigger[data-state="open"] {
    color: #6c63ff;
  }

  .faq-panel {
    overflow: hidden;
    transition: height 0.25s ease;
  }

  .faq-panel[data-state="closed"] {
    height: 0;
  }

  .faq-panel[data-state="open"] {
    height: auto;   / use ResizeObserver in practice for smooth animation /
  }
*/

Context Performance and Granularity

The single most common mistake in headless component design is a monolithic context object where every field update re-renders every subscriber. A Select with 50 items will trigger 50 re-renders when the user presses ArrowDown to move the highlight by one—if all items read from the same context that includes highlightedIndex.

Split Context by Update Frequency

Separate stable values (dispatch function, IDs) from volatile values (current state). Every item reads the dispatch context (never changes) and only computes its own derived values. The pattern below demonstrates context splitting:

// Stable context: dispatch and IDs never change after mount
const SelectDispatchContext = createContext<React.Dispatch<SelectAction> | null>(null);
// Volatile context: state changes on every interaction
const SelectStateContext = createContext<SelectState | null>(null);

export function Select({ children, ...props }: SelectProps) {
  const [state, dispatch] = useReducer(selectReducer, initialState);

  // dispatch is stable (useReducer guarantees this)
  // Only wrap state in a separate provider so state changes don't
  // force re-renders on components that only need dispatch.
  return (
    <SelectDispatchContext.Provider value={dispatch}>
      <SelectStateContext.Provider value={state}>
        {children}
      </SelectStateContext.Provider>
    </SelectDispatchContext.Provider>
  );
}

// SelectItem only subscribes to the fields it needs
export function SelectItem({ value, children }: SelectItemProps) {
  const dispatch = useContext(SelectDispatchContext)!;
  const state = useContext(SelectStateContext)!;

  // Compute derived booleans; React.memo will bail out if these are unchanged
  const isSelected = state.value === value;
  const isHighlighted = state.items[state.highlightedIndex] === value;

  return (
    <li
      role="option"
      aria-selected={isSelected}
      data-selected={isSelected ? "" : undefined}
      data-highlighted={isHighlighted ? "" : undefined}
      onClick={() => dispatch({ type: "SELECT", value })}
    >
      {children}
    </li>
  );
}

For extreme cases—virtualized lists of hundreds of items—consider using useSyncExternalStore with an external state atom (Zustand, Jotai) per item, letting each item subscribe to only the slice of state it cares about. This eliminates all inter-item re-renders regardless of how many items the list contains.

Memoizing Context Values

Always memoize the context value object with useMemo. Without memoization, every render of the root creates a new object reference, causing every context consumer to re-render even when the actual values are identical. This is a silent performance bug in virtually every hand-rolled context provider.

Common Pitfalls

Further Reading