Headless components: buy the state machine, own the skin
A widget is 80% state machine, keyboard contract, ARIA and focus — headless libraries ship that and zero pixels; you own skin, motion, density. Choose headless when brand is strong, a styled kit when speed wins; wrapping a styled kit means fighting its specificity forever.
The in-house dropdown looked perfect — the design team had polished every pixel for the brand. Then an enterprise prospect required a WCAG audit before signing, and the dropdown failed eleven criteria in one afternoon: ArrowDown did nothing, Escape left the popup open, focus vanished into the page when it closed, the screen reader announced “button, collapsed” and then went silent as options changed. The component had taken six weeks to build two years earlier; the team estimated another five to retrofit the behavior — keyboard contract, aria-activedescendant, focus restore, typeahead, scroll-into-view of the highlighted option, plus the iOS VoiceOver quirks nobody budgets for. The fix that actually shipped took eight days: the visual layer was lifted off the old component and draped over a headless primitive that already implemented the entire WAI-ARIA listbox pattern. The pixels did not change; the deal closed. Six weeks of original effort, and the part that was hard — the invisible 80% — was the part they had built worst and could have bought for free. That is the entire headless argument in one audit.
The 80% you cannot see
A select is not a styled list. It is a state machine: closed → open (by click, Enter, Space, ArrowDown), an active-option index moved by arrows and Home/End, a typeahead buffer that collects printable characters and expires after a timeout, selection that may or may not close the popup, and a pointer-versus-keyboard modality switch that decides whether hover moves the highlight. Around the machine sits the keyboard contract (the WAI-ARIA pattern specifies every key), the ARIA wiring (role="listbox", role="option", aria-expanded, aria-activedescendant so the screen reader tracks the highlight while DOM focus stays on the trigger), and focus management — trap or not, restore on close, what happens when the popup unmounts mid-interaction. The accessibility unit covers the contracts themselves; this lesson is about who should implement them. Estimating honestly: a robust combobox from scratch is weeks of work — published prior art from teams that did it lands in the 4–8 week range — followed by a bug tail measured in quarters: IME composition events, VoiceOver on iOS, scroll containers, RTL. The skin — colors, radius, density, motion — is days. The ratio is the whole argument: the part you can see is the cheap part.
The headless landscape, honestly
Headless means the library ships behavior and no pixels. Three families, genuinely different:
- Radix Primitives ship components that render DOM:
Select.Trigger,Select.Content,Select.Item— a compound family with the state machine, keyboard, ARIA and focus built in, unstyled. You style the rendered elements throughclassNameand data attributes (data-state="open",data-highlighted,data-disabled) that expose the machine’s state to CSS. - React Aria ships hooks:
useSelect,useListBox,useOptionreturn prop-getters you spread onto elements you render. Maximal control of the DOM, Adobe’s a11y rigor underneath (it powers React Spectrum), more assembly required — you wire the parts together yourself. - TanStack Table and Virtual are headless in the purest sense: not UI libraries at all but data logic — sorting, grouping, column state, virtual windows — returning plain state and functions, zero DOM, zero opinion about markup.
What you inherit for free: the keyboard contract, the ARIA wiring, focus management, the typeahead, the portal and dismissal logic. What you still own — and this is where teams get burned — is everything visible: every state the machine exposes needs a visual answer (data-highlighted with no CSS rule means keyboard users navigate blind), motion, density, dark mode, and the content-level semantics: an icon-only button still needs an accessible name, and no library can write your labels.
A team adopts Radix Dialog, ships an icon-only close button and auto-focuses the destructive Delete action. The a11y audit still fails the dialog. What did headless actually buy them?
Build on headless versus buy a styled kit
The honest decision is not “headless is better” — it is a three-way trade between brand strength, team capacity, and time:
- A styled kit (MUI, Mantine) wins when the brand is weak or internal — admin panels, back-office tools, prototypes — and when the team is small or has no design support. You accept the kit’s look and its opinions, and in exchange ship complete, accessible widgets this sprint. That is a good trade, taken with eyes open.
- Headless plus your skin wins when the brand is the product, a real design team exists, and the system will live for years. You pay days per widget for the skin and keep total visual control; the behavior is maintained, audited, and bug-fixed upstream.
- From scratch is justified only where no primitive exists for the interaction — a custom canvas editor, a novel gesture surface — and then the WAI-ARIA pattern documents are the spec you implement against.
The failure mode that fills postmortems is the hybrid nobody chose deliberately: wrapping a styled kit and fighting it. A strong-brand team adopts MUI “to move fast”, then overrides it — first with theme tokens (supported), then with nested selectors targeting internal class names (not a contract), then !important archaeology. Every kit upgrade may legally rename those internals; one team’s minor-version bump broke 40 override sites at once. You end up paying for both layers: the kit’s bundle and opinions, plus your override stylesheet that is effectively a fork of its DOM. The styled kit’s look is the product you bought — buying it in order to fight it means owning the war. If the brand must win, buy behavior without pixels; that is the entire reason headless exists.
A startup with a strong brand wraps a styled kit, overriding internal class names to match design. Sixteen months later a minor kit upgrade breaks 40 override sites. What is the root cause?
What the split looks like in practice
With Radix, the skin is CSS against data attributes — the machine’s state is the styling API:
.item[data-highlighted] {
background: var(--surface-raised); /* keyboard highlight — without this rule,
arrow navigation is invisible */
}
.content[data-state="open"] {
animation: slide-in 120ms var(--ease-out);
}
.item[data-disabled] {
opacity: 0.5;
pointer-events: none;
}With React Aria, the same split happens in JSX — hooks own behavior, you own every element:
function Option({ item, state }) {
const ref = useRef(null);
const { optionProps, isFocused, isSelected } = useOption(
{ key: item.key }, state, ref
);
return (
<li {...optionProps} ref={ref}
className={isFocused ? "item item-active" : "item"}>
{item.rendered}
{isSelected ? <CheckIcon /> : null}
</li>
);
}Either way the contract is identical: the library guarantees that data-highlighted or isFocused is correct — set by arrows, typeahead, hover under pointer modality, cleared on close — and you guarantee that every such state has a visible answer. Review checklists for headless adoptions are mostly this: enumerate the machine’s states, prove each one renders.
- 01Decompose what a headless primitive ships versus what the adopting team still owns, with the failure mode on each side of the line.
- 02Lay out the build-on-headless versus styled-kit decision and explain why wrapping a styled kit to force a brand is the worst position.
The visible part of a widget is the cheap part. Underneath a production select sits a state machine — open/close transitions, a highlighted index, a typeahead buffer with expiry, pointer-versus-keyboard modality — wrapped in the WAI-ARIA keyboard contract, the ARIA wiring that lets a screen reader track the highlight through aria-activedescendant, and focus management with trap, restore, and dismissal. Building that from scratch runs weeks and then bleeds edge cases for quarters; styling it is days. Headless libraries exist to sell exactly the hard part with zero pixels, in three genuinely different shapes: Radix Primitives ship unstyled DOM-rendering compound components styled through data attributes like data-state and data-highlighted; React Aria ships hooks returning prop-getters over elements you render, with Adobe’s a11y rigor and more assembly; TanStack Table and Virtual ship pure data logic with no DOM at all. Adopting one, you inherit the keyboard contract, ARIA, and focus behavior — and you still owe a visual answer for every state the machine exposes, all motion and density decisions, and the content-level semantics no library can write, like accessible names and sane focus targets. The decision between headless and a styled kit is honest only as a trade: weak or internal brand with a small team favors the styled kit and its ready-made look; a strong brand with a real design team and a multi-year horizon favors headless plus your skin. The position to refuse is the accidental hybrid — wrapping a styled kit and overriding its internals to force a brand it was not built for, paying for its bundle and your override fork simultaneously, one minor upgrade away from 40 broken call sites. Now when you see an in-house dropdown fail an accessibility audit, you know exactly which 80% was missing — and you know that the fix is not a rewrite but a skin transplant onto the primitive that already has it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.