Headless Components: Build Flexible Custom Interfaces
A headless component provides behavior, logic, and state without any built-in UI. The consumer receives the state, methods, and ARIA attributes they need to build the interface from scratch. This pattern is the opposite of a "batteries-included" component: instead of getting a styled modal with one appearance, you get the behavior engine and build the modal however you want. Libraries like Headless UI and React Aria have popularized this approach because it lets one component serve design systems that look completely different.
I discovered headless components while integrating three different design systems into one application. Rather than maintaining three sets of duplicate logic, we used a headless component for behavior and wrapped it with each system's styling. The result: one logic codebase, three visual systems, zero duplication.
The Core Pattern: Behavior Without UI
Basic Headless Component Structure
// A headless dropdown: provides behavior, state, and ARIA attributes, but NO UI
function useDropdown(defaultOpen = false) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
const triggerRef = React.useRef(null);
const listRef = React.useRef(null);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
const toggle = () => setIsOpen(!isOpen);
const handleKeyDown = (e) => {
if (!isOpen) return;
if (e.key === 'ArrowDown') {
setHighlightedIndex(prev => (prev + 1) % 3); // adjust based on items
e.preventDefault();
} else if (e.key === 'ArrowUp') {
setHighlightedIndex(prev => (prev === 0 ? 2 : prev - 1));
e.preventDefault();
} else if (e.key === 'Escape') {
close();
}
};
return {
isOpen,
highlightedIndex,
triggerProps: {
onClick: toggle,
onKeyDown: handleKeyDown,
'aria-haspopup': 'listbox',
'aria-expanded': isOpen,
ref: triggerRef,
},
listProps: {
role: 'listbox',
onKeyDown: handleKeyDown,
ref: listRef,
},
itemProps: (index) => ({
role: 'option',
'aria-selected': highlightedIndex === index,
onClick: () => {
console.log(`Selected item ${index}`);
close();
},
className: highlightedIndex === index ? 'highlighted' : '',
}),
};
}
// Consumer builds the UI using the headless hook
export function MyDropdown() {
const { isOpen, triggerProps, listProps, itemProps } = useDropdown();
return (
<div>
<button {...triggerProps} style={{ padding: '8px' }}>
Choose an option
</button>
{isOpen && (
<ul {...listProps} style={{ listStyle: 'none', padding: 0 }}>
{['Option A', 'Option B', 'Option C'].map((label, idx) => (
<li key={idx} {...itemProps(idx)} style={{ padding: '8px' }}>
{label}
</li>
))}
</ul>
)}
</div>
);
}
The useDropdown hook provides all the behavior (state, keyboard handling, ARIA attributes); the consumer builds the UI from scratch. Different consumers can style it completely differently—or even use a different structure entirely (buttons instead of a list, for example).
Real-World: Headless Data Table
Data tables are perfect for headless patterns because sorting, filtering, and pagination logic is reusable, but table UI varies wildly by design system:
// Headless hook for table logic
function useTable(data, { sortBy = null, pageSize = 10 } = {}) {
const [sortConfig, setSortConfig] = React.useState(sortBy);
const [pageIndex, setPageIndex] = React.useState(0);
// Sort data
let sortedData = [...data];
if (sortConfig) {
sortedData.sort((a, b) => {
const aVal = a[sortConfig.key];
const bVal = b[sortConfig.key];
if (aVal < bVal) return sortConfig.dir === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.dir === 'asc' ? 1 : -1;
return 0;
});
}
// Paginate
const totalPages = Math.ceil(sortedData.length / pageSize);
const pageData = sortedData.slice(
pageIndex * pageSize,
(pageIndex + 1) * pageSize
);
const handleSort = (key) => {
setSortConfig(prev => {
if (prev?.key === key) {
// Toggle direction
return { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' };
}
return { key, dir: 'asc' };
});
setPageIndex(0);
};
return {
pageData,
totalPages,
pageIndex,
setPageIndex,
sortConfig,
handleSort,
getHeaderProps: (key) => ({
onClick: () => handleSort(key),
'aria-sort': sortConfig?.key === key
? (sortConfig.dir === 'asc' ? 'ascending' : 'descending')
: 'none',
style: { cursor: 'pointer' },
}),
};
}
// Consumer 1: Simple table UI
export function SimpleTable() {
const users = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
];
const table = useTable(users);
return (
<div>
<table>
<thead>
<tr>
<th {...table.getHeaderProps('name')}>Name</th>
<th {...table.getHeaderProps('email')}>Email</th>
</tr>
</thead>
<tbody>
{table.pageData.map(user => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
<div>
{[...Array(table.totalPages)].map((_, i) => (
<button
key={i}
onClick={() => table.setPageIndex(i)}
style={{ fontWeight: table.pageIndex === i ? 'bold' : 'normal' }}
>
{i + 1}
</button>
))}
</div>
</div>
);
}
// Consumer 2: Card grid UI (different structure, same logic)
export function CardTable() {
const users = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
];
const table = useTable(users);
return (
<div style={{ display: 'grid', gap: '1rem' }}>
{table.pageData.map(user => (
<div key={user.id} style={{ border: '1px solid gray', padding: '1rem' }}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
);
}
Same sorting and pagination logic; completely different UIs. One codebase, infinite designs.
Headless Modal with Custom Styling
A headless modal gives you the behavior (focus trapping, backdrop dismissal, ARIA attributes) without the styling:
// Headless modal hook
function useModal(defaultOpen = false) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const contentRef = React.useRef(null);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
// Focus trap: keep focus inside modal when open
React.useEffect(() => {
if (!isOpen || !contentRef.current) return;
const focusableElements = contentRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
} else if (e.key === 'Escape') {
close();
}
};
contentRef.current.addEventListener('keydown', handleKeyDown);
firstElement?.focus();
return () => contentRef.current?.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
return {
isOpen,
open,
close,
backdropProps: {
onClick: close,
role: 'presentation',
},
contentProps: {
role: 'dialog',
'aria-modal': true,
ref: contentRef,
},
};
}
// Consumer: custom styled modal
export function MyModal() {
const modal = useModal();
return (
<>
<button onClick={modal.open}>Open Modal</button>
{modal.isOpen && (
<div
{...modal.backdropProps}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
{...modal.contentProps}
style={{
background: 'white',
padding: '2rem',
borderRadius: '8px',
width: '90%',
maxWidth: '500px',
}}
>
<h2>Modal Title</h2>
<p>Modal content goes here.</p>
<button onClick={modal.close}>Close</button>
</div>
</div>
)}
</>
);
}
The hook handles focus trapping and keyboard events; the consumer owns all styling and layout. Perfect for design systems.
Key Takeaways
- Headless components provide behavior, state, and ARIA attributes without built-in UI, letting consumers build custom interfaces.
- This pattern is ideal for design systems, multi-brand applications, and any scenario where the same logic needs different visual presentations.
- Headless components are typically implemented as hooks (returning state and handler props) rather than wrapper components.
- They work especially well for complex interactions (data tables, modals, dropdowns) where logic is reusable but styling varies.
- The consumer is responsible for building the entire UI, which gives maximum flexibility but requires more code at the call site.
Frequently Asked Questions
Is a headless component just a custom hook?
Mostly, yes. A custom hook that returns state and props is headless. Some libraries return a component factory function instead, but the principle is the same: behavior without UI.
When should I use headless vs. a styled component?
Use headless when you need flexibility across multiple designs or teams. Use styled when you have one canonical design and want to minimize code at the call site. Many libraries offer both.
Can I add unstyled HTML to a headless component?
Technically, but that defeats the purpose. A headless component should return state and props, not JSX. If you need to return elements, you're not fully headless.
Do I need to build all the UI myself when using a headless component?
Yes. The headless library gives you the behavior; you build the UI using standard HTML and CSS. This is more work upfront but gives you complete control.
Which popular libraries use the headless pattern?
Headless UI, React Aria, Radix UI, and TanStack Table all use headless-first or headless-only patterns. They're widely adopted in production systems.