what is "headless"? logic vs rendering
A headless component owns the plumbing
— state, keyboard handling, ARIA wiring, focus management — but never renders a single
<div>. The consumer owns the DOM. Two halves
connect them: a state reducer (override any transition) and
prop getters (spread the right props onto the right element).
Invented by Kent C. Dodds, now powering react-table, downshift, Radix UI, Headless UI.
| half | who owns it | what it gives the consumer |
|---|---|---|
state reducer |
consumer passes a custom (state, action) → newState |
control over EVERY state transition — intercept, block, or rewrite changes |
prop getters |
component exposes getXxxProps(extra) helpers |
spread the right a11y + event props without knowing internals |
render |
consumer writes the JSX, picks tags & classes | 100% styling freedom — Tailwind, CSS-in-JS, plain CSS, anything |
plumbing |
component keeps it (a11y, keyboard, focus, IDs) | correctness the consumer doesn't have to think about |
1 · the headless useToggle hook (edit me)
2 · Babel compiles JSX → element tree
The JSX spread {'{...getToggleButtonProps({})}'} becomes a
_extends(...) call merging props into React.createElement.
Clicking dispatches {'{type:"TOGGLE", changes:{isOpen:...}}'} — the
consumer reducer decides whether those changes survive.
// (hit "compile & render" to see Babel's output)
3 · live React (the pattern, proven)
The gold-check clicks the default toggle twice (closed → open → closed) and asserts the state text after each click. That proves the prop-getter spread pipeline and the default state reducer work through real DOM events. Try the bottom button yourself: open it 3 times and watch the custom reducer veto the close.
state-1: — · gold: click toggle-btn → expect closed→open→closed
prop getter convention
| intent | pattern | why |
|---|---|---|
| render the trigger | <button {...getToggleButtonProps()}> |
gets aria-expanded, onClick, type=button, role in one spread |
| render the listbox/menu | <ul {...getMenuProps()}> |
gets role=listbox, aria-labelledby, keyboard handler |
| render an option | <li {...getItemProps({item, index})}> |
gets role=option, aria-selected, id, onClick, onMouseDown |
| override a prop | getXxxProps({ className: 'mine', onClick: fn }) |
extra props win via Object.assign — consumer styling/behavior composes |
| veto a transition | if (...) return state; in custom reducer |
return current state to ignore proposed changes |
| rewrite a transition | return { ...action.changes, extra: true } |
return a modified changes object — full control of next state |