Headless Components and Hooks
The Styling Problem
You build a beautiful dropdown. Custom styles, animations, perfect spacing. Then a new project starts with a different design system. Your dropdown is useless because the styling is baked in. You build another one.
This is the core tension: behavior (keyboard navigation, focus management, ARIA attributes, state logic) is universal. Styling is not. Headless components solve this by shipping only the behavior and letting consumers bring their own styles.
Think of headless components like a car engine sold without the body. The engine (behavior) works with any chassis (styling) you bolt it into. A sedan, an SUV, a sports car — same engine, completely different appearance. Headless components are the engine. Your design system is the body.
The Headless Pattern
A headless component provides:
- State management (open/closed, selected/unselected, focused item)
- Keyboard interactions (arrow keys, Enter, Escape, Home/End)
- ARIA attributes (roles, aria-expanded, aria-selected, aria-activedescendant)
- Focus management (trap focus, restore focus, roving tabindex)
It does NOT provide:
- Any HTML structure (you choose the elements)
- Any CSS (you bring your own styles)
- Any animations (you add them yourself)
// STYLED component — beautiful but inflexible
<Select options={options} value={selected} onChange={setSelected} />
// HEADLESS component — behavior only, you own the rendering
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger>
<SelectValue placeholder="Pick a language" />
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
<LangIcon name={lang.icon} />
<span>{lang.label}</span>
{lang.popular && <Badge>Popular</Badge>}
</SelectItem>
))}
</SelectContent>
</Select>
The headless version gives you full control over what each item looks like, what extra elements you include, and how everything is styled. The keyboard navigation and ARIA roles come for free.
Custom Hooks as Headless Components
Before Radix and React Aria existed, the headless pattern lived in custom hooks. A hook encapsulates behavior and returns props you spread onto your elements.
function useToggle(defaultPressed = false) {
const [pressed, setPressed] = useState(defaultPressed);
const buttonProps = {
role: "switch" as const,
"aria-checked": pressed,
onClick: () => setPressed((p) => !p),
onKeyDown: (e: KeyboardEvent) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
setPressed((p) => !p);
}
},
};
return { pressed, setPressed, buttonProps };
}
// Usage — consumer owns all the styling
function DarkModeToggle() {
const { pressed, buttonProps } = useToggle();
return (
<button
{...buttonProps}
className={cn(
"relative h-8 w-14 rounded-full transition-colors",
pressed ? "bg-indigo-600" : "bg-zinc-300",
)}
>
<span
className={cn(
"absolute top-1 left-1 h-6 w-6 rounded-full bg-white transition-transform",
pressed && "translate-x-6",
)}
/>
</button>
);
}
The hook handles the ARIA role, checked state, and keyboard interaction. The consumer decides it looks like a sliding toggle with Tailwind classes. If tomorrow the design changes to a checkbox style, only the JSX changes. The hook stays identical.
Building a Headless Combobox
Let us build something real. A combobox (autocomplete input) is one of the hardest UI patterns to get right. The behavior is complex: filtering, keyboard navigation, screen reader announcements, focus management.
interface UseComboboxOptions<T> {
items: T[];
itemToString: (item: T) => string;
onSelectedItemChange?: (item: T | null) => void;
}
function useCombobox<T>({ items, itemToString, onSelectedItemChange }: UseComboboxOptions<T>) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [selectedItem, setSelectedItem] = useState<T | null>(null);
const listboxId = useId();
const filteredItems = items.filter((item) =>
itemToString(item).toLowerCase().includes(inputValue.toLowerCase()),
);
function selectItem(item: T) {
setSelectedItem(item);
setInputValue(itemToString(item));
setIsOpen(false);
setHighlightedIndex(-1);
onSelectedItemChange?.(item);
}
const inputProps = {
role: "combobox" as const,
"aria-expanded": isOpen,
"aria-controls": listboxId,
"aria-activedescendant":
highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined,
"aria-autocomplete": "list" as const,
value: inputValue,
onChange: (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
setIsOpen(true);
setHighlightedIndex(0);
},
onKeyDown: (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setIsOpen(true);
setHighlightedIndex((i) => Math.min(i + 1, filteredItems.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((i) => Math.max(i - 1, 0));
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && filteredItems[highlightedIndex]) {
selectItem(filteredItems[highlightedIndex]);
}
break;
case "Escape":
setIsOpen(false);
setHighlightedIndex(-1);
break;
}
},
onFocus: () => setIsOpen(true),
onBlur: () => {
setTimeout(() => setIsOpen(false), 150);
},
};
function getItemProps(index: number) {
return {
id: `${listboxId}-option-${index}`,
role: "option" as const,
"aria-selected": index === highlightedIndex,
onClick: () => selectItem(filteredItems[index]),
onMouseEnter: () => setHighlightedIndex(index),
};
}
const listboxProps = {
id: listboxId,
role: "listbox" as const,
};
return {
isOpen,
inputValue,
highlightedIndex,
selectedItem,
filteredItems,
inputProps,
listboxProps,
getItemProps,
};
}
Simplified for teaching — a production combobox also needs Home/End keys (jump to first/last item), Page Up/Down (scroll by page), and type-ahead search. Libraries like Downshift and React Aria handle all of these.
Now the consumer can render this however they want:
function LanguagePicker({ languages }: { languages: Language[] }) {
const {
isOpen,
highlightedIndex,
filteredItems,
inputProps,
listboxProps,
getItemProps,
} = useCombobox({
items: languages,
itemToString: (lang) => lang.name,
});
return (
<div className="relative">
<input {...inputProps} className="w-full rounded-lg border px-4 py-2" />
{isOpen && filteredItems.length > 0 && (
<ul {...listboxProps} className="absolute mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg">
{filteredItems.map((lang, index) => (
<li
key={lang.id}
{...getItemProps(index)}
className={cn(
"flex cursor-pointer items-center gap-2 px-4 py-2",
index === highlightedIndex && "bg-indigo-50",
)}
>
<span className="text-xl">{lang.flag}</span>
<span>{lang.name}</span>
</li>
))}
</ul>
)}
</div>
);
}
The onBlur handler uses setTimeout to delay closing. Without this, clicking an option triggers onBlur before onClick, closing the listbox before the click registers. This race condition is one of the most common bugs in custom combobox implementations. Production libraries like Downshift use onMouseDown with preventDefault instead, which is more robust.
Radix UI and React Aria
You rarely need to build headless components from scratch. Three libraries dominate this space:
| Feature | Radix UI | React Aria (Adobe) | Base UI (MUI) |
|---|---|---|---|
| Approach | Compound components with Context | Hooks that return props to spread | Render props for full control |
| Styling | Bring your own CSS/Tailwind | Bring your own CSS/Tailwind | Bring your own CSS/Tailwind |
| ARIA compliance | Built-in, follows WAI-ARIA APG | Built-in, follows WAI-ARIA APG | Built-in, follows WAI-ARIA APG |
| Animations | Data attributes for CSS transitions | Hooks for animation states | Data attributes + CSS hooks |
| Bundle size | Per-component imports, tree-shakeable | Per-hook imports, tree-shakeable | Per-component imports, tree-shakeable |
| Polymorphism | asChild with Slot component | elementType prop | render prop |
| Server Components | Client-only (interactive) | Client-only (interactive) | Client-only (interactive) |
| Extras | Primitives focused on web | Toast (alpha), Autocomplete, Virtualizer, Tree with DnD | Used by shadcn/ui, backed by MUI team |
Radix uses compound components:
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">Options</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => edit()}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => duplicate()}>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => remove()} className="text-red-500">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
React Aria uses hooks:
function MyDropdownMenu({ items }: MenuProps) {
const state = useMenuTriggerState({});
const ref = useRef(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
return (
<>
<Button {...menuTriggerProps} ref={ref}>Options</Button>
{state.isOpen && (
<Popover state={state}>
<Menu {...menuProps} items={items}>
{(item) => <MenuItem>{item.name}</MenuItem>}
</Menu>
</Popover>
)}
</>
);
}
Base UI uses render props — you pass a render prop to swap the underlying element:
import { Button } from "@base-ui-components/react/button";
import Link from "next/link";
function MyNavButton() {
return (
<Button render={<Link href="/dashboard" />}>
Dashboard
</Button>
);
}
All three give you full accessibility. Radix is the most ergonomic for most use cases. React Aria gives you finer control when you need to compose behaviors in unusual ways. Base UI (v1.0 stable since Dec 2025) sits between the two — render props are more explicit than asChild but less boilerplate than hooks.
The Accessibility Guarantee
Here is the thing most people miss about headless components: the accessibility is not an add-on. It is the whole point.
Building an accessible dropdown from scratch requires handling:
- 15+ keyboard interactions (ArrowDown, ArrowUp, Home, End, Enter, Space, Escape, type-ahead, Tab)
- 8+ ARIA attributes (role, aria-expanded, aria-haspopup, aria-controls, aria-activedescendant, aria-selected, aria-disabled, aria-label)
- Focus management (restore focus on close, trap focus in submenus)
- Screen reader announcements (live regions for dynamic content)
That is months of work to do correctly. A headless library gives you all of it by default. Using Radix or React Aria is not laziness — it is the responsible engineering choice.
- 1Use headless libraries (Radix, React Aria, Base UI) for standard patterns — dropdown, dialog, tabs, combobox, tooltip
- 2Build custom headless hooks only for genuinely novel interactions
- 3Always test with keyboard and screen reader — headless components make this easier, not optional
- 4Headless does not mean unstyled — it means behavior-first with full styling freedom
| What developers do | What they should do |
|---|---|
| Building a custom dropdown from div and onClick handlers Custom dropdown implementations almost always fail keyboard navigation, screen reader announcements, and focus restoration. The bugs are subtle and affect real users. | Use Radix DropdownMenu or React Aria useMenu — they handle 50+ edge cases you will miss |
| Choosing a styled component library like MUI then overriding every style Overriding opinionated styles means fighting specificity, understanding the library's internal class structure, and dealing with version upgrades that change class names. Starting headless avoids this entirely. | Choose a headless library and style from scratch — you will fight less |
You need to build an accessible combobox with multi-select, tag display, and async search. Walk through how you would architect this. Would you use a headless library or build from scratch? What accessibility concerns would you prioritize? How would you handle the keyboard interaction model for removing tags?