Headless Components: Behavior Without Presentation
Understand the headless-UI pattern — separating interaction behavior, state management, and accessibility from visual rendering — and build headless primitives using render props and compound components so consumers get full styling control without reimplementing keyboard and ARIA logic.
Every overlay and disclosure component built in the previous lesson had two things tangled together: the behavior (focus trapping, keyboard handling, ARIA state, open/closed logic) and the presentation (specific markup, specific class names, specific visual design). A headless component deliberately untangles them — it ships only the behavior, exposing state and event handlers through render props or context, and leaves 100% of the markup and styling to the consumer. This is the pattern behind libraries like Radix UI (primitives), Headless UI, React Aria, and Downshift, and it is what lets a design system team ship one correct implementation of 'accessible dropdown behavior' that a hundred visually distinct dropdowns across a company can all build on.
Behavior vs. presentation: why separate them
A conventional component like the Modal from the previous lesson bundles its focus-trap logic with a specific <div className="modal-backdrop"> markup structure. That's fine until a second team needs the exact same focus-trap and Escape-to-close behavior but a completely different visual design — a full-screen mobile sheet instead of a centered dialog, say. With a presentational component, they either fork the component (duplicating the accessibility logic, which now can drift and diverge) or fight the existing styles with overrides.
A headless component solves this by never rendering any markup of its own for the styled parts — it manages state (is this open? which item is highlighted? what's the current tab?) and exposes the props needed to wire that state to arbitrary markup (onClick, role, aria-expanded, tabIndex) through a render prop, a hook, or a set of context-connected sub-components. The consumer supplies every element and every class name; the headless layer supplies every behavior.
This is a genuine architectural trade-off, not a strictly-better replacement for presentational components: headless components require more integration work per use (the consumer must correctly spread the returned props onto real elements) and are overkill for a component with one fixed visual design system-wide. They earn their complexity when the same behavior needs to support meaningfully different visual treatments, or when a library is meant to be consumed by teams outside the design system's own visual language.
The render-props pattern for exposing behavior
A render prop is a component prop whose value is a function that returns JSX — the component calls that function internally, passing it whatever state and handlers the consumer needs, and renders whatever comes back. This is the oldest and most explicit way to expose headless behavior: <Toggle>{({ isOn, toggle }) => <button onClick={toggle}>{isOn ? 'On' : 'Off'}</button>}</Toggle>.
The advantage of render props over a plain hook is that the headless component can still own some structural responsibility — deciding what DOM node to portal into, managing an internal ref, wiring aria-* relationships between elements it doesn't render itself — while a bare hook only returns values and leaves 100% of the wiring, including getting refs onto the right elements, to the consumer. Modern headless libraries increasingly favor hooks (useDisclosure(), useTabsState()) for simple cases and reserve render props / component composition for cases needing that structural ownership.
TypeScript makes render props easy to type precisely: the render function's parameter type is exactly the shape of state and handlers the component exposes, so consumers get full autocomplete on what a render prop gives them — children: (state: { isOpen: boolean; open: () => void; close: () => void }) => React.ReactNode.
Compound components: implicit shared state via context
A compound component is a group of components designed to be used together, where the parent manages shared state internally and distributes it to its children implicitly via React Context rather than requiring the consumer to thread props between siblings manually: <Tabs><Tabs.List><Tabs.Tab index={0}>One</Tabs.Tab></Tabs.List><Tabs.Panels><Tabs.Panel index={0}>...</Tabs.Panel></Tabs.Panels></Tabs>.
This API shape is expressive for two reasons: it mirrors the actual DOM/semantic structure the consumer is building (a Tabs.List really does correspond to role="tablist"), and it lets the consumer freely rearrange, restyle, or omit sub-components — put the tab list on the right instead of the top, wrap a Tabs.Tab in a tooltip, skip Tabs.Panels entirely and render panel content elsewhere — without the parent component needing a prop for every possible arrangement.
The mechanism underneath is always the same: the top-level component creates a Context with the shared state (active index, setters, generated ids for ARIA linking) and a Provider; each sub-component reads that context via a custom hook (useTabsContext()) that throws a clear error if used outside the parent, which turns 'I rendered Tabs.Tab outside Tabs' from a silent undefined bug into an immediate, legible error message.
Designing a headless component: what to expose, what to own
The design question for any headless component is: which parts of the WAI-ARIA pattern's requirements can the consumer be trusted to wire up correctly, and which parts should the headless layer own outright because getting them wrong is too easy and too costly? Almost universally, headless implementations still generate ids and manage the ARIA relationships between elements themselves (via context, not left to the consumer) because a mismatched aria-controls/id pair is a subtle bug a consumer is unlikely to catch in manual testing but a screen reader user will immediately notice.
What the headless layer does NOT own: DOM element choice (<button> vs <a> vs a custom component), class names, animation, layout, and visual state (what 'open' looks like). It DOES own: whether the interaction is even legal right now (can't open an already-open dialog twice), keyboard event handling, focus movement, and the ARIA attribute values that must stay consistent across multiple elements.
A useful design heuristic: a prop/getter is 'headless-appropriate' if getting it wrong is (a) an accessibility bug and (b) not obviously visual. getTriggerProps() returning { onClick, 'aria-expanded', 'aria-haspopup', id } is headless-appropriate. A triggerClassName prop is not — that's presentation, and belongs entirely to the consumer.
• Headless layer owns: legality of state transitions, keyboard handling, focus movement, and ARIA attribute wiring between elements. • Consumer owns: DOM element choice, class names / CSS, animation, and all visual states. • Compound components use context internally so state moves between siblings without prop drilling, while sub-components can be freely rearranged in the tree. • A prop getter (getTriggerProps()) bundling several ARIA attributes at once reduces the chance a consumer wires up only some of the required attributes.
A headless disclosure hook and its render-prop wrapper
triggerProps/contentProps are 'prop getters' — spread directly onto whatever element the consumer chooses, guaranteeing the aria-controls/id pair always matches because both come from the same baseId. Exposing both a hook (useDisclosure) and a render-props wrapper (Disclosure) lets consumers pick whichever composition style fits their codebase.
import { useCallback, useId, useState } from "react";
interface DisclosureState {
isOpen: boolean;
triggerProps: {
id: string;
"aria-expanded": boolean;
"aria-controls": string;
onClick: () => void;
};
contentProps: { id: string; hidden: boolean };
}
// The hook owns state + ARIA id linkage; it renders nothing itself.
function useDisclosure(initialOpen = false): DisclosureState {
const [isOpen, setIsOpen] = useState(initialOpen);
const baseId = useId();
const toggle = useCallback(() => setIsOpen((v) => !v), []);
return {
isOpen,
triggerProps: {
id: `${baseId}-trigger`,
"aria-expanded": isOpen,
"aria-controls": `${baseId}-content`,
onClick: toggle,
},
contentProps: { id: `${baseId}-content`, hidden: !isOpen },
};
}
// A render-props wrapper for consumers who prefer JSX composition to hooks.
function Disclosure({ children }: { children: (state: DisclosureState) => React.ReactNode }) {
return <>{children(useDisclosure())}</>;
}Code Examples
The Toggle component never renders a <button> or <div> itself — it only computes isOn and the ARIA/keyboard-correct buttonProps object, then hands both to whatever the consumer's render function returns. Both usages get correct role="switch", aria-checked, and Space/Enter keyboard activation for free, despite rendering completely different markup.
return <>{children({ isOn, toggle, buttonProps })}</>;
Tabs owns the active-index state and the id scheme linking each Tab to its TabPanel, distributed via context; useTabsContext throws immediately if a sub-component is rendered outside Tabs, converting a silent bug into a clear error during development.
if (!ctx) throw new Error(<Tabs.${component}> must be rendered inside <Tabs>);
Common mistake: Passing activeIndex/setActiveIndex down as explicit props through every intermediate layer (prop drilling) instead of context works for a shallow tree but breaks the moment a consumer wants to insert their own wrapper <div> between Tabs and Tabs.List — context keeps the API robust to arbitrary consumer-side markup insertion.
useCombobox is a pure hook — no render prop, no JSX of its own — that owns filtering and highlight/keyboard logic and exposes two prop getters (inputProps, getOptionProps). This is the 'reusability' payoff of headless design: a second team can build a visually unrelated combobox reusing the exact same filtering and keyboard logic just by calling the same hook.
const { inputProps, filtered, getOptionProps } = useCombobox(options);
interface ToggleRenderProps {
isOn: boolean;
toggle: () => void;
buttonProps: {
role: "switch";
"aria-checked": boolean;
onClick: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
};
}
function Toggle({
defaultOn = false,
children,
}: {
defaultOn?: boolean;
children: (props: ToggleRenderProps) => React.ReactNode;
}) {
const [isOn, setIsOn] = React.useState(defaultOn);
const toggle = () => setIsOn((v) => !v);
const buttonProps: ToggleRenderProps["buttonProps"] = {
role: "switch",
"aria-checked": isOn,
onClick: toggle,
onKeyDown: (e) => {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); toggle(); }
},
};
return <>{children({ isOn, toggle, buttonProps })}</>;
}
// Two completely different visual treatments, same behavior + accessibility:
<Toggle>
{({ isOn, buttonProps }) => (
<button {...buttonProps} className={isOn ? "switch-on" : "switch-off"}>
{isOn ? "Enabled" : "Disabled"}
</button>
)}
</Toggle>
<Toggle defaultOn>
{({ isOn, buttonProps }) => (
<div {...buttonProps} tabIndex={0} className="pill-toggle">
<span className={`pill-dot ${isOn ? "right" : "left"}`} /
…interface TabsContextValue {
activeIndex: number;
setActiveIndex: (i: number) => void;
baseId: string;
}
const TabsContext = React.createContext<TabsContextValue | null>(null);
function useTabsContext(component: string): TabsContextValue {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error(`<Tabs.${component}> must be rendered inside <Tabs>`);
return ctx;
}
function Tabs({ defaultIndex = 0, children }: { defaultIndex?: number; children: React.ReactNode }) {
const [activeIndex, setActiveIndex] = React.useState(defaultIndex);
const baseId = React.useId();
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex, baseId }}>
<div className="tabs-root">{children}</div>
</TabsContext.Provider>
);
}
function TabsList({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>;
}
function Tab({ index, children }: { index: number; children: React.ReactNode }) {
const { activeIndex, setActiveIndex, baseId } = useTabsContext("Tab");
const isActive = index === activeIndex;
return (
<button
role="tab"
id={`${baseId}-tab-${index}`}
aria-selected={isActive}
aria-co
…interface ComboboxOption { id: string; label: string; }
function useCombobox(options: ComboboxOption[]) {
const [query, setQuery] = React.useState("");
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
const filtered = React.useMemo(
() => options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase())),
[options, query]
);
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") setHighlightedIndex((i) => Math.min(i + 1, filtered.length - 1));
if (e.key === "ArrowUp") setHighlightedIndex((i) => Math.max(i - 1, 0));
}
return {
query,
setQuery,
filtered,
highlightedIndex,
inputProps: {
value: query,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => { setQuery(e.target.value); setHighlightedIndex(0); },
onKeyDown,
role: "combobox" as const,
"aria-expanded": filtered.length > 0,
"aria-autocomplete": "list" as const,
},
getOptionProps: (index: number) => ({
role: "option" as const,
"aria-selected": index === highlightedIndex,
onMouseEnter: () => setHighlightedIndex(index),
}),
};
}
// Consumer supplies 100% of the markup
…Headless vs. presentational component boundary
A headless component draws the boundary at behavior: everything visual crosses to the consumer's side, while everything accessibility-critical (state legality, keyboard handling, ARIA wiring) stays owned by the shared layer.
Presentational component Headless component
┌───────────────────────┐ ┌───────────────────────┐
│ owns: state │ │ owns: state │
│ owns: behavior/ARIA │ │ owns: behavior/ARIA │
│ owns: markup & styles │ │ (renders NOTHING) │
└───────────┬─────────────┘ └───────────┬─────────────┘
│ fixed visual output │ hook / render-prop / context
v v
<StyledDialog /> consumer's own <div>, <button>, CSS
(one look, everywhere) (any look, behavior still correct)Important Rules
A headless component renders no markup of its own for the styled surface — it exposes state and prop getters via a hook, render prop, or compound-component context.
The headless layer owns anything where getting it wrong is an accessibility bug and not a visual choice: keyboard handling, focus movement, ARIA attribute consistency, and legal-state-transition logic.
Compound sub-components read shared state through a context-backed custom hook that throws a clear error when used outside its parent, rather than silently returning undefined.
Prop getters (getTriggerProps()) bundle every required ARIA attribute together so a consumer can't accidentally wire up only some of them.
Prefer a plain hook when the consumer only needs values and handlers; use render props or compound components when the headless layer must also own structural responsibilities like id generation shared across multiple elements.
Why this pattern dominates modern component libraries
Radix UI Primitives, React Aria (Adobe), Headless UI (Tailwind Labs), and Downshift are all headless-first: they ship zero default styling and instead ship a correctly-implemented WAI-ARIA pattern per component, letting every team apply its own design system on top. This split is precisely why a design system team can build the styled Dropdown, Tabs, and Accordion from the previous lesson ON TOP of one of these headless libraries rather than re-deriving focus management and ARIA wiring from scratch — the headless layer is reused; only the presentation layer changes per product.
function useDisclosure() { return { isOpen, toggle }; } // consumer must invent its own aria-controls id matching function useDisclosure() { const id = useId(); return { isOpen, triggerProps: { 'aria-controls': ${id}-content,... }, contentProps: { id: ${id}-content } }; }
Leaving id generation to the consumer means two independently-written id/aria-controls values can silently drift out of sync (e.g. after a refactor); generating both from the same source inside the hook makes a mismatch structurally impossible.
const ctx = useContext(TabsContext); ctx.setActiveIndex(1); // no null check, crashes with 'Cannot read property of null' if used outside <Tabs> function useTabsContext() { const ctx = useContext(TabsContext); if (!ctx) throw new Error('<Tabs.Tab> must be rendered inside <Tabs>'); return ctx; }
An unchecked context read fails with an unhelpful low-level error far from the actual mistake; a dedicated hook that throws a specific, named error turns a confusing bug report into an immediately actionable message.
What is the defining characteristic that makes a component 'headless'? A. It has no TypeScript types B. It renders no markup or styling of its own for the interactive surface, exposing only state and behavior via hooks, render props, or context C. It cannot be used with React Context D. It only works with class components
B. It renders no markup or styling of its own for the interactive surface, exposing only state and behavior via hooks, render props, or context — A headless component's defining trait is the absence of owned presentation — it manages state and behavior (including accessibility) and hands prop getters or render-prop arguments to the consumer, who supplies all markup and styling.
In a compound component like Tabs with Tabs.List, Tabs.Tab, and Tabs.Panel, how does shared state (like the active tab index) typically flow between the sub-components? A. Through explicit props passed down at every level (prop drilling) B. Through React Context set up by the parent Tabs component and read by each sub-component via a custom hook C. Through global module-level variables D. Sub-components each manage their own independent copy of the state
B. Through React Context set up by the parent Tabs component and read by each sub-component via a custom hook — Compound components use a context Provider in the parent and a context-consuming custom hook in each sub-component, which lets consumers rearrange the sub-components freely in the tree without needing to manually pass shared state as props.
Why is useCombobox in the example a plain hook (not a render-props component), while Toggle in the same lesson uses render props? A. Hooks are always a strictly newer and better pattern than render props B. Because useCombobox only needs to return values/handlers for the consumer to spread onto elements it fully controls, while render props suit cases needing more structural composition ownership C. Render props cannot return ARIA attributes D. Because combobox components cannot use React Context
B. Because useCombobox only needs to return values/handlers for the consumer to spread onto elements it fully controls, while render props suit cases needing more structural composition ownership — Both patterns expose headless behavior, but a plain hook is sufficient when the consumer only needs prop getters to spread onto elements it already controls; render props and compound components are reached for when the headless layer needs more structural involvement, such as shared id generation across sibling elements it doesn't itself render.
Build a headless useRadioGroup(options: string[]) hook that manages a selected value and exposes a getOptionProps(option: string) prop getter returning { role: 'radio', 'aria-checked', tabIndex, onClick, onKeyDown }, where only the selected option has tabIndex={0} (others -1) and ArrowDown/ArrowUp move selection between options — following the same roving-tabindex pattern used by the Tabs example.
function useRadioGroup(options: string[]) { const [selected, setSelected] = useState(options[0]); const refs = useRef<Record<string, HTMLElement | null>>({}); function move(delta: number) { const i = options.indexOf(selected); const next = options[(i + delta + options.length) % options.length]; setSelected(next); refs.current[next]?.focus(); } return { selected, getOptionProps: (option: string) => ({ role: 'radio' as const, 'aria-checked': option === selected, tabIndex: option === selected ? 0 : -1, ref: (el: HTMLElement | null) => { refs.current[option] = el; }, onClick: () => setSelected(option), onKeyDown: (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') move(1); if (e.key === 'ArrowUp') move(-1); } }) }; }
Roving tabindex means exactly one item in the group is ever a real Tab stop at a time; moving selection with arrow keys should both update selected state and imperatively call .focus() on the newly-selected option's DOM node so keyboard focus visibly follows selection.