Difficulty: Advanced
What are headless components in React? How do they differ from UI component libraries?
Headless components are one of those concepts that, once you understand them, completely changes how you think about building reusable UI. Let me explain them by starting with the problem they solve.
Imagine you're building a dropdown select component. It needs to handle opening and closing the menu, keyboard navigation (arrow keys to move between options, Enter to select, Escape to close), focus management, ARIA attributes for screen readers, positioning the dropdown relative to the trigger, and scroll locking. That's a lot of complex behavior. Now imagine you build all of that and also bake in specific CSS styles -- border radius, shadows, colors, padding, font sizes.
Your company uses this component across three projects. Project A has a dark theme. Project B has a completely different design system with rounded corners and different spacing. Project C needs the dropdown to look like a native OS select. Now you're stuck trying to make one component's CSS flexible enough to handle all three cases, and it becomes a mess of configuration props: variant='dark', size='large', borderRadius={8}, dropdownWidth='auto'... You end up with a prop API the size of a dictionary, and it still can't handle every design requirement.
Headless components solve this by separating the two concerns entirely. The behavioral logic (state management, keyboard handling, accessibility, focus management) is provided by the headless component or hook. The visual rendering (what HTML elements to use, what CSS classes to apply, how things look) is entirely up to you, the consumer.
There are three main patterns for implementing headless components:
First, custom hooks. This is the simplest approach. A hook like useCombobox returns state (isOpen, selectedItem, highlightedIndex) and prop-getter functions (getInputProps, getMenuProps, getItemProps). You call these functions and spread the returned props onto your own elements. The hook handles all the ARIA attributes, keyboard listeners, and state transitions. You decide what elements to render and how to style them. The Downshift library pioneered this pattern.
Second, compound components. This is the pattern used by Radix UI and Headless UI. You get a set of related components -- Select.Root, Select.Trigger, Select.Content, Select.Item -- that share state through React Context internally. Each sub-component handles its own ARIA role and keyboard behavior. You compose them together and apply your own className to each one. The library handles the behavior; you handle the appearance.
Third, render props. This is the older approach where a component calls a function (the render prop) with its state, and you return JSX from that function. It's less common now because hooks are generally cleaner, but you'll still see it in some libraries.
The prop-getter pattern deserves special attention because it's really clever. Instead of just returning raw state, a headless hook returns functions like getToggleProps() that generate a complete set of props for a specific element. So getToggleProps() might return { role: 'switch', 'aria-checked': isOn, onClick: toggle, onKeyDown: handleKeyDown, tabIndex: 0 }. You spread these onto your button element, and all the accessibility and interaction behavior is wired up automatically. If you need to add your own props, you pass them as an argument: getToggleProps({ className: 'my-button', 'data-testid': 'toggle' }), and the function merges them intelligently.
Now, the tradeoff. Compared to traditional component libraries like Material UI or Ant Design, headless components require more code from you. With MUI, you write <Select options={options} /> and you get a fully styled, functional select. With Radix, you write out all the sub-components, apply your own styles, and handle the layout. It's more work, but the result is a component that looks exactly like your design, not like a slightly customized version of Material Design.
This is why shadcn/ui has become so popular. It's built on top of Radix UI (headless) and Tailwind CSS (utility styles). It gives you pre-styled components that you copy directly into your project as source code, not as an npm dependency. You get the convenience of a styled component library with the full control of owning the code. If you need to change something, you just edit the file.
In terms of real-world usage, Tanstack Table (formerly React Table) is another great example of the headless pattern. It provides all the logic for sorting, filtering, pagination, grouping, and column resizing, but it renders zero DOM elements. You use its hook, get back the state and handlers, and render your own table, div grid, or whatever structure you want.
When should you choose headless over opinionated UI libraries? If your project has a strong, unique design system that doesn't match any existing component library, headless is the right call. If you're prototyping quickly or building an internal tool where design consistency with Material Design or similar is fine, an opinionated library will get you there faster. For most production applications with a dedicated design team, headless libraries hit the sweet spot of robust behavior with complete styling freedom.
// Headless: pure behavior, no UI
function useToggle(initialState = false) {
const [isOn, setIsOn] = useState(initialState);
const toggle = useCallback(() => setIsOn(v => !v), []);
const turnOn = useCallback(() => setIsOn(true), []);
const turnOff = useCallback(() => setIsOn(false), []);
return {
isOn,
toggle,
turnOn,
turnOff,
// Return props for accessible button
getToggleProps: (props = {}) => ({
role: 'switch',
'aria-checked': isOn,
onClick: toggle,
...props
})
};
}
// Consumer 1: Simple checkbox style
function Toggle1() {
const { isOn, getToggleProps } = useToggle();
return (
<button {...getToggleProps()} className={`toggle ${isOn ? 'on' : 'off'}`}>
{isOn ? 'ON' : 'OFF'}
</button>
);
}
// Consumer 2: Completely different visual
function Toggle2() {
const { isOn, toggle } = useToggle();
return (
<div onClick={toggle} style={{ color: isOn ? 'green' : 'red' }}>
{isOn ? '✓ Active' : '✗ Inactive'}
</div>
);
}
The hook encapsulates state and ARIA. Different consumers can render entirely different UIs with the same behavior.
// Using Radix UI - headless + accessible
import * as Select from '@radix-ui/react-select';
function CustomSelect({ options, value, onChange }) {
return (
<Select.Root value={value} onValueChange={onChange}>
{/* Trigger: consumer controls styling */}
<Select.Trigger className="my-custom-trigger">
<Select.Value placeholder="Select option" />
<Select.Icon>▼</Select.Icon>
</Select.Trigger>
{/* Portal + Content: keyboard nav handled by Radix */}
<Select.Portal>
<Select.Content className="my-custom-dropdown">
<Select.Viewport>
{options.map(opt => (
<Select.Item key={opt.value} value={opt.value}>
<Select.ItemText>{opt.label}</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}
// Radix handles:
// - Keyboard navigation (arrow keys, Home/End)
// - ARIA (role=listbox, aria-selected, etc.)
// - Focus management
// - Portal rendering
// You style it however you want
Radix provides all accessibility and keyboard behavior. You write all the CSS. This is the headless component library model.
Headless Components, Inversion of Control, Render Props, Custom Hooks, Separation of Concerns