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.
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 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.
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.
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.
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.
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>
);
}
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 <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>;
}
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>
);
}
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;
}
*/
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 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: <Select.Trigger asChild><MyButton /></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 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.
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.
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.
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 /
}
*/
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.
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.
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.
SelectItem components conditionally render, unmounted items must unregister from the items list. A missing useEffect cleanup leaves stale values in the navigation order, causing keyboard navigation to skip to items that are no longer rendered.value prop provided) must not update internal state on selection—it must only call onValueChange and wait for the parent to re-render with the new value. Mixing internal and external state leads to double-update bugs and flickering.position: absolute clips inside any ancestor with overflow: hidden or a stacking context (transform, filter, will-change). Production headless libraries use ReactDOM.createPortal to render dropdown content at the document body level, then use Floating UI or Popper.js to compute the correct position. Skipping this causes dropdowns to appear truncated or mispositioned in real layouts.<button> if the consumer may need an <a> or a custom component. Either use render props, the asChild/Slot pattern, or accept an as prop that defaults to "button" to keep the DOM element flexible.useContext call details and lets them bypass the null-check guard. Always export a custom hook (useSelectContext) that includes the null check and throws a descriptive error when used outside the provider.