Headless UI Patterns in React
A practical guide to building flexible, reusable, style agnostic headless UI components in React.
Headless UI Patterns in React
Headless UI is a pattern that separates logic from presentation. The component controls state and behavior while leaving all markup and styling to the consumer. This pattern is used in modern design systems and UI libraries because it gives developers full visual freedom without sacrificing predictable logic.
This article explains the pattern clearly, shows how to implement it in React, and provides real world examples that scale in production systems.
1. What Headless UI Means
A headless component does not render its own layout. It exposes:
- state
- actions
- event handlers
- accessibility helpers
The consumer decides how the UI looks.
Example of a headless toggle hook:
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
return {
on,
toggle: () => setOn((v) => !v),
setOn
};
}Usage with custom UI:
const toggle = useToggle();
<button onClick={toggle.toggle}>
{toggle.on ? "On" : "Off"}
</button>The hook manages behavior. The UI is entirely customizable.
2. Why Headless UI Matters
Headless components solve real problems:
Problem 1. Components that restrict styling
Teams often fight against rigid components that force markup or CSS structures.
Problem 2. Hard to integrate with custom design systems
Headless logic works with any HTML structure and any design system.
Problem 3. Difficult to reuse logic without duplicating UI
The logic lives in one place. The UI can vary by use case.
Problem 4. Mixed concerns
UI, behavior, and state are combined in one file. Headless UI splits these into clear boundaries.
3. Headless UI With Render Props
Render props expose behavior through a function.
function Dropdown({ children }) {
const [open, setOpen] = useState(false);
return children({
open,
toggle: () => setOpen((v) => !v)
});
}Usage:
<Dropdown>
{({ open, toggle }) => (
<>
<button onClick={toggle}>Menu</button>
{open && <div className="menu">...</div>}
</>
)}
</Dropdown>This gives complete visual control.
4. Headless UI With Context and Compound Components
This pattern is cleaner for multi part components like modals, dropdowns, and accordions.
Step 1. Create context driven logic
const AccordionContext = createContext(null);
function Accordion({ children }) {
const [open, setOpen] = useState(false);
const value = {
open,
toggle: () => setOpen((v) => !v)
};
return (
<AccordionContext.Provider value={value}>
{children}
</AccordionContext.Provider>
);
}Step 2. Separate UI components
function AccordionTrigger({ children }) {
const { toggle } = useContext(AccordionContext);
return <button onClick={toggle}>{children}</button>;
}
function AccordionContent({ children }) {
const { open } = useContext(AccordionContext);
return open ? <div>{children}</div> : null;
}Usage:
<Accordion>
<AccordionTrigger>Show Details</AccordionTrigger>
<AccordionContent>Here are the details...</AccordionContent>
</Accordion>The logic is shared. The UI remains flexible.
5. Building a Headless Select Component
Logic:
function useSelect(items) {
const [selected, setSelected] = useState(null);
return {
items,
selected,
select: (item) => setSelected(item)
};
}Usage:
const select = useSelect(["A", "B", "C"]);
<ul>
{select.items.map((item) => (
<li key={item} onClick={() => select.select(item)}>
{item} {select.selected === item ? "(selected)" : ""}
</li>
))}
</ul>6. Accessibility in Headless UI
A headless design system should expose accessibility helpers.
function useDialog() {
const [open, setOpen] = useState(false);
return {
open,
triggerProps: {
"aria-haspopup": "dialog",
onClick: () => setOpen(true)
},
dialogProps: {
role: "dialog",
"aria-modal": true
},
close: () => setOpen(false)
};
}Usage:
const dialog = useDialog();
<button {...dialog.triggerProps}>Open</button>
{dialog.open && (
<div {...dialog.dialogProps}>
Modal content
<button onClick={dialog.close}>Close</button>
</div>
)}7. Anti Patterns to Avoid
- Forcing DOM structure
- Mixing styling inside the logic
- Bloated configuration props
- Hooks that try to manage both logic and rendering
Final Thoughts
Headless UI patterns help teams build flexible and reusable components that scale with evolving design requirements. By separating logic from presentation, React applications become easier to maintain, more customizable, and more predictable.