Headless components
Headless components give you behavior, state, accessibility, and keyboard handling with zero styling or markup — you supply the UI — so you get fully custom looks without fighting a library; the failure mode is abstracting a one-look in-app widget.
You need a combobox that matches a pixel-exact design. You reach for a component library and immediately start fighting it: overriding its CSS with !important, wrapping its markup to inject your own, patching its DOM structure to satisfy the designer. Two days later you have a styled component you don’t trust and didn’t fully build. Or you skip the library and hand-roll it — and now you own the keyboard arrow navigation, the active-descendant ARIA, the focus trap, the type-ahead, and the screen-reader announcements. Both roads are expensive.
A headless component is the third road. It ships the hard, invisible 80% — state, keyboard handling, focus management, ARIA wiring — and ships nothing visible. No styles, no markup opinions. You bring every <div>, every class, every pixel. You inherit correctness for free and pay zero in look-and-feel taxes.
After this lesson you can define a headless component as behavior + state + accessibility with no styling or markup; consume a headless primitive by spreading its prop-getters and state onto your own elements; explain why this is the modern answer to “reusable behavior, fully custom look” — you inherit keyboard, focus, and ARIA without fighting a library’s CSS; and name the failure mode — building a headless abstraction for a single in-app widget that has exactly one look is speculative generality.
Headless means the library owns behavior, you own the DOM. A styled component bundles three things: behavior (open/close, selection, keyboard), accessibility (roles, aria-*, focus), and presentation (markup + CSS). A headless component keeps the first two and hands you the third. It exposes its work as hooks, render props, or prop-getters — never as finished markup.
// A styled library: you get its <div>, its classes, its structure. You override.
<Combobox className="lib-combobox" /* now fight its internals */ />
// A headless library: it gives you behavior + a11y props; YOU write the elements.
const { getInputProps, getMenuProps, getItemProps, isOpen } = useCombobox({ items });
return (
<div className="my-combobox">
<input {...getInputProps()} className="my-input" />
<ul {...getMenuProps()} className={isOpen ? "my-menu open" : "my-menu"}>
{isOpen && items.map((item, i) => (
<li key={item.id} {...getItemProps({ item, index: i })} className="my-row">
{item.label}
</li>
))}
</ul>
</div>
);The library decides what the input and each row must announce and how arrow keys move selection; you decide what they look like and where they sit.
You inherit the accessibility and keyboard work that is genuinely hard to get right. This is the real payoff. A correct listbox needs role, aria-activedescendant, aria-expanded, aria-controls, roving focus, Home/End/PageUp/PageDown handling, type-ahead, and announcements — a multi-week project to do well, and a perennial source of bugs when hand-rolled. A headless primitive bakes this into its prop-getters, so spreading them onto your elements gives you a WAI-ARIA-compliant widget with your own skin.
// Radix is headless: unstyled, accessible primitives you compose and style yourself.
import * as Dialog from "@radix-ui/react-dialog";
export function ConfirmDialog() {
return (
<Dialog.Root>
<Dialog.Trigger className="btn">Delete</Dialog.Trigger>
<Dialog.Portal>
{/* focus trap, Esc-to-close, scroll lock, aria-modal, restore-focus: all free */}
<Dialog.Overlay className="overlay" />
<Dialog.Content className="card">
<Dialog.Title className="title">Delete project?</Dialog.Title>
<Dialog.Close className="btn-danger">Confirm</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Every className is yours. Every focus trap, Esc handler, and aria-modal is the library’s. You did not write the part most teams get wrong.
Custom look has no cost, because there are no styles to override. With a styled kit, customization is subtraction — you remove and override what shipped. With headless, customization is the default state: nothing ships styled, so you never reverse-engineer a library’s CSS specificity or shadow-DOM. The same headless useReactTable powers a dense admin grid and an airy marketing table; the behavior is shared, the markup diverges completely.
import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table";
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
// TanStack Table is headless: it gives you the row model + state, no <table> at all.
return (
<table className="my-grid">
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="my-row">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="my-cell">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);Sorting, filtering, pagination, grouping — all behavior, zero markup. You render whatever the design demands.
Failure mode: a headless abstraction for one in-app widget with exactly one look is speculative generality. Headless earns its keep when behavior is shared but appearance varies — across an app, a design system, or a library’s many consumers. If you have a single dropdown used in one place with one design, building your own useDropdown headless layer (prop-getters, generic state, render props) buys you nothing: there is no second look to vary, no third consumer to serve. You’ve paid the abstraction’s full cost — indirection, a generic API, more files — for a flexibility you will never use.
// Over-engineering: a homegrown headless layer for ONE component with ONE look.
function useDropdown<T>() { /* prop-getters, generic state, a render-prop API… */ }
function AppMenu() {
const { getToggleProps, getItemProps, isOpen } = useDropdown<MenuItem>();
// …used in exactly one place, styled exactly one way, forever
}
// Senior choice: a plain component. Reach for headless only when variation is real.
function AppMenu() {
const [open, setOpen] = useState(false);
return /* the one markup this app needs */;
}Consuming a published headless library (Radix, Headless UI) is cheap and almost always right — someone already paid the abstraction cost and the a11y is battle-tested. Authoring your own headless layer is only worth it once you genuinely have multiple looks over one behavior.
Same tabs widget, two builds — watch the headless seam remove the work you’d otherwise own. A design needs tabs with a fully custom underline animation and bespoke spacing.
Build A hand-rolls everything, including the accessibility nobody remembers until an audit:
function Tabs({ tabs }: { tabs: Tab[] }) {
const [active, setActive] = useState(0);
return (
<div className="tabs">
{/* No role="tablist", no aria-selected, no arrow-key navigation,
no aria-controls, no roving tabindex — keyboard + SR users are stranded */}
<div className="tabrow">
{tabs.map((t, i) => (
<button key={t.id} className={i === active ? "tab on" : "tab"} onClick={() => setActive(i)}>
{t.label}
</button>
))}
</div>
<div className="panel">{tabs[active].content}</div>
</div>
);
}It looks right and ships broken for keyboard and screen-reader users. Build B consumes a headless primitive (Headless UI / Radix Tabs) and supplies only markup and classes:
import { Tab } from "@headlessui/react";
function Tabs({ tabs }: { tabs: Tab[] }) {
return (
<Tab.Group> {/* arrow-key nav, roving tabindex, role+aria-selected: all included */}
<Tab.List className="tabrow">
{tabs.map((t) => (
<Tab key={t.id} className={({ selected }) => (selected ? "tab on" : "tab")}>
{t.label} {/* your underline animation lives entirely in your CSS */}
</Tab>
))}
</Tab.List>
<Tab.Panels>
{tabs.map((t) => <Tab.Panel key={t.id} className="panel">{t.content}</Tab.Panel>)}
</Tab.Panels>
</Tab.Group>
);
}B has the identical custom look as A — the underline, the spacing, every class is yours — but the keyboard navigation and ARIA are correct because the headless library owns them. You wrote the 20% that’s design-specific and inherited the 80% that’s error-prone. The cost (a dependency, a small API to learn) is paid by the library’s author once, for every consumer. That is exactly when reaching for headless is the senior choice — and exactly why hand-rolling your own headless layer for one in-app look is not.
▸Why this works
Why has headless become the dominant answer to “reusable behavior, custom look”? Because the two older answers each fail at scale. Fully-styled kits (old Bootstrap, MUI defaults) give you speed but trap you in their look; escaping it means an override war against their CSS that compounds with every upgrade. Hand-rolling gives you full control but re-implements the hard, invisible accessibility layer in every project, badly. Headless splits the difference cleanly: the part that is the same everywhere and hard to get right (keyboard, focus, ARIA, state) is shared and battle-tested; the part that is different everywhere and easy (markup, CSS) stays fully yours. Radix, Headless UI, TanStack Table, Downshift, and React Aria all sell exactly this seam.
▸Common mistake
The trap isn’t consuming headless libraries — that’s almost always right. The trap is authoring a headless abstraction prematurely. A team reads about prop-getters and render props, gets excited, and wraps their single in-app <Sidebar> or <Modal> in a generic useDisclosure + render-prop API “so it’s reusable”. But it’s used once, styled once, and the abstraction makes the code harder to read for a reuse that never comes. The rule: consume published headless primitives freely; only build your own headless layer once you have at least two real, differently-styled consumers of the same behavior. Until then, a plain component is the senior choice.
Your app has one settings dropdown, used on a single screen, with exactly one design that will never vary. A teammate proposes building a generic useDropdown headless hook (prop-getters, render-prop API) 'for reuse'. What's the senior call?
A headless component ships behavior, state, accessibility, and keyboard/focus handling with zero styling or markup — it exposes its work through hooks, render props, or prop-getters and lets you bring every element and class yourself. That seam is the modern answer to “reusable behavior, fully custom look”: you inherit the hard, error-prone 80% (roving focus, aria-*, type-ahead, focus traps) without fighting a library’s CSS, and you own the easy, design-specific 20% (markup + pixels) completely. Consuming a published headless primitive — Radix, Headless UI, TanStack Table — is cheap and almost always the senior choice, because someone already paid the abstraction cost and the a11y is battle-tested. Authoring your own headless layer is the opposite call: doing it for a single in-app widget with exactly one look is speculative generality — you pay full price in indirection and a generic API for a flexibility you’ll never use. The rule that separates the two: headless earns its keep only when one behavior must wear many looks. Until that variation is real, reach for a plain component.
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.