Headless Components: Give Away the Behavior, Keep None of the Markup

Here's a thing that took me too long to internalize: the most reusable UI code I've ever written renders nothing. No <div>, no class name, no markup at all. It just computes behavior and hands it to whoever called it. That sounds backwards — a UI component that produces no UI? — but it's the idea behind every component library that survived contact with more than one design system.
Compound components took us most of the way there: let the caller control structure while the component owns the coordination. But a compound component still ships markup. <Select.Option> renders a div, picks a class, decides on padding. The moment two products with different designs both want that select behavior, that markup becomes a fight. Headless components are the answer to "what if the component gave up the markup entirely and kept only the behavior."
The coupling nobody notices until it hurts
Think about what a <Combobox> actually does. It manages an open/closed state. It tracks the highlighted option. It handles arrow keys, Enter, Escape, typeahead. It wires up the ARIA attributes so screen readers announce it correctly. It filters options as you type. That's a lot of genuinely hard logic — the kind with edge cases you'll get wrong the first three times.
And then, almost as an afterthought, it renders some divs with some classes.
The hard part — the behavior — is completely universal. Every combobox everywhere needs the same keyboard handling and the same accessibility wiring. The easy part — the markup and styles — is completely specific. Your combobox looks nothing like mine. Yet a normal component welds those two together, so when I want your combobox's excellent keyboard handling but my design, I can't have it. I either fork your styles or reimplement your logic. Both are bad, and I've done both.
The behavior is universal. The markup is personal. A normal component fuses them and forces you to take both or neither.
Headless components unfuse them. They keep all the hard, universal behavior and give away zero markup, letting you supply the personal part yourself.
Three shapes of the same idea
"Headless" isn't one API. It's a family of three techniques that all separate behavior from rendering. They're worth knowing as a set, because you'll pick between them by feel.
Render props / children-as-a-function. The component computes state and calls your function to render. It decides nothing about looks.
<Toggle> {({ on, toggle }) => ( <button onClick={toggle} className={on ? 'bg-[#22c55e]' : 'bg-gray-300'}> {on ? 'On' : 'Off'} </button> )} </Toggle>
Toggle owns the on/off logic. You own every pixel. It renders literally nothing except by calling your function.
Hooks-as-API. The same idea, but the behavior is a hook instead of a component. This is the modern default, and honestly the cleanest — no wrapper component in the tree at all, just logic you call and props you spread.
function ColorSwatch() { const toggle = useToggle(); return ( <button onClick={toggle.toggle} aria-pressed={toggle.on} className={toggle.on ? 'bg-[#22c55e]' : 'bg-gray-300'} > {toggle.on ? 'On' : 'Off'} </button> ); }
Prop getters. For complex widgets, the hook hands you functions that return the props you need to spread onto your elements — including all the fiddly ARIA and event wiring you'd never remember to add yourself.
function Search() { const { getInputProps, getMenuProps, getItemProps, items, isOpen } = useCombobox({ items: options }); return ( <div> <input {...getInputProps()} /> <ul {...getMenuProps()}> {isOpen && items.map((item, i) => ( <li key={item.id} {...getItemProps({ item, index: i })}>{item.label}</li> ))} </ul> </div> ); }
You wrote the input, the ul, the li — your elements, your styles. But getInputProps() spread the ARIA roles, the keyboard handlers, the focus management onto them. You got the world-class behavior and kept total control of the markup. That's the whole promise in one code block, and it's why libraries like Downshift, React Aria, TanStack Table, and the Radix primitives are built exactly this way.
Why this is dependency injection wearing a costume
If this feels familiar, it should. Back in the composition and DI article I argued that a component should be handed its dependencies instead of reaching out to grab them. Headless components are that same principle turned inside out: instead of injecting data or services into a component, you're injecting the rendering into a behavior. The behavior doesn't reach out and decide how things look — you hand the looks in.
That's why the same benefits show up. A headless useCombobox is trivially testable, because you can drive its logic without rendering any particular UI. It's endlessly reusable, because it makes no assumption about markup. And it lets you change your mind about design without touching the logic, because the logic never knew what it looked like in the first place. Separation of concerns stops being a slogan and becomes a physical fact: the concern that's hard and universal lives in one place, the concern that's easy and specific lives in yours, and they meet through a thin, explicit interface.
The cost, honestly
I'm not going to pretend this is free, because the failure mode is real and I've watched teams walk into it. Headless components move work to the caller. When you use a styled <Button>, you get a button and you're done. When you use a headless toggle, you have to render the button, style it, and handle the states yourself. Multiply that across every component and a fully headless design system can feel like it gives you a pile of hooks and a homework assignment.
So the real-world answer is usually a layer cake, not a religion. Headless primitives at the bottom — the hard behavior, unstyled. A styled layer on top of them — your design system's actual <Button>, <Combobox>, <Select>, built once on the headless base and used everywhere. Most of your app imports the styled layer and never thinks about the headless one. The headless layer is there for the 10% of cases where the styled component doesn't fit and you need to drop down and rebuild the look while keeping the behavior. You get convenience by default and an escape hatch when you need it — instead of choosing between a rigid component library and reinventing keyboard navigation.
That layering is also the answer to the UI-kit-across-many-apps problem I've written about before: the behavior is shared once at the headless layer, and each app skins it without forking the logic. Same accessibility, same keyboard handling, different paint.
What I actually reach for
My rule of thumb is short. If a component's behavior is hard and its look varies — combobox, date picker, dropdown menu, data table, anything with keyboard and ARIA — I want it headless underneath, styled on top. If a component's look is fixed and its behavior is trivial, a plain styled component is right and headless is overkill. Most of a codebase is the second kind. But the handful of components that are the first kind are exactly the ones that cause the most pain when they're not headless, because they're the ones everyone needs slightly differently.
The mindset shift, though, is the real takeaway — the same one compound components started. You stop thinking "I'm building a combobox" and start thinking "I'm building combobox behavior, and the combobox is just one thing someone might render with it." Once you see UI logic as separable from UI markup, you can't unsee it, and you start noticing how much supposedly-reusable code isn't reusable at all — not because the logic is wrong, but because it's handcuffed to one specific set of divs.
That wraps the two most important component patterns — compose the structure, then hand off the behavior. Next in this run I want to get at the unglamorous half of building shared components: how you change one that dozens of places already depend on, without a giant risky rewrite — deprecation paths, codemods, and updating call sites without fear. If you've got a component library, go find the one component everyone reimplements slightly differently. That's almost always a behavior that should have been headless — tell me which one it is in yours.
Found this helpful?
Subscribe on Telegram for new posts, or reach out to discuss your project.