Imagine that on a spaceship you have a universal control module that can be connected to any panel -- the same module handles both the cockpit and the engineering panel, but each displays data in its own way. This is exactly the idea of Headless Components -- components that contain all the logic but zero UI.
A Headless Component is a component or hook that:
1// Headless Dropdown -- all logic, zero UI
2function useDropdown(options) {
3 const [isOpen, setIsOpen] = useState(false);
4 const [selectedIndex, setSelectedIndex] = useState(-1);
5 const [selectedOption, setSelectedOption] = useState(null);
6
7 const toggle = () => setIsOpen(prev => !prev);
8 const close = () => setIsOpen(false);
9
10 const select = (index) => {
11 setSelectedIndex(index);
12 setSelectedOption(options[index]);
13 setIsOpen(false);
14 };
15
16 // Keyboard navigation
17 const handleKeyDown = (e) => {
18 if (e.key === 'ArrowDown') {
19 setSelectedIndex(prev => Math.min(prev + 1, options.length - 1));
20 } else if (e.key === 'ArrowUp') {
21 setSelectedIndex(prev => Math.max(prev - 1, 0));
22 } else if (e.key === 'Enter' && selectedIndex >= 0) {
23 select(selectedIndex);
24 } else if (e.key === 'Escape') {
25 close();
26 }
27 };
28
29 return {
30 isOpen, selectedIndex, selectedOption,
31 toggle, close, select, handleKeyDown,
32 getToggleProps: () => ({
33 onClick: toggle,
34 onKeyDown: handleKeyDown,
35 'aria-expanded': isOpen,
36 'aria-haspopup': 'listbox',
37 }),
38 getOptionProps: (index) => ({
39 onClick: () => select(index),
40 'aria-selected': selectedIndex === index,
41 role: 'option',
42 }),
43 };
44}The key to headless components -- same hook, different appearances:
1// Version 1: Simple dropdown
2function SimpleDropdown({ options, label }) {
3 const dropdown = useDropdown(options);
4
5 return (
6 <div className="simple-dropdown">
7 <button {...dropdown.getToggleProps()}>
8 {dropdown.selectedOption || label}
9 </button>
10 {dropdown.isOpen && (
11 <ul role="listbox">
12 {options.map((opt, i) => (
13 <li key={i} {...dropdown.getOptionProps(i)}>
14 {opt}
15 </li>
16 ))}
17 </ul>
18 )}
19 </div>
20 );
21}
22
23// Version 2: Space dropdown with icons
24function SpaceDropdown({ options, icons }) {
25 const dropdown = useDropdown(options);
26
27 return (
28 <div className="space-dropdown">
29 <div {...dropdown.getToggleProps()} className="space-trigger">
30 <span>{dropdown.selectedOption || 'Select system'}</span>
31 <span>{dropdown.isOpen ? '▲' : '▼'}</span>
32 </div>
33 {dropdown.isOpen && (
34 <div className="space-menu">
35 {options.map((opt, i) => (
36 <div key={i} {...dropdown.getOptionProps(i)} className="space-option">
37 <span>{icons[i]}</span>
38 <span>{opt}</span>
39 </div>
40 ))}
41 </div>
42 )}
43 </div>
44 );
45}Another example -- headless toggle for building accordions, expandable sections, spoilers:
1function useToggle(initialState = false) {
2 const [isOpen, setIsOpen] = useState(initialState);
3
4 return {
5 isOpen,
6 toggle: () => setIsOpen(p => !p),
7 open: () => setIsOpen(true),
8 close: () => setIsOpen(false),
9 getToggleProps: () => ({
10 onClick: () => setIsOpen(p => !p),
11 'aria-expanded': isOpen,
12 }),
13 getPanelProps: () => ({
14 role: 'region',
15 hidden: !isOpen,
16 }),
17 };
18}
19
20// Usage 1: Accordion
21function SystemDetails({ title, children }) {
22 const { isOpen, getToggleProps, getPanelProps } = useToggle();
23
24 return (
25 <div className="accordion-item">
26 <button {...getToggleProps()}>
27 {title} {isOpen ? '−' : '+'}
28 </button>
29 <div {...getPanelProps()}>
30 {isOpen && children}
31 </div>
32 </div>
33 );
34}
35
36// Usage 2: Tooltip
37function InfoTooltip({ content, children }) {
38 const { isOpen, getToggleProps, getPanelProps } = useToggle();
39
40 return (
41 <span className="tooltip-wrapper">
42 <span {...getToggleProps()} className="tooltip-trigger">
43 {children}
44 </span>
45 <div {...getPanelProps()} className="tooltip-content">
46 {isOpen && content}
47 </div>
48 </span>
49 );
50}1function useTabs(tabCount, defaultTab = 0) {
2 const [activeTab, setActiveTab] = useState(defaultTab);
3
4 return {
5 activeTab,
6 setActiveTab,
7 getTabProps: (index) => ({
8 onClick: () => setActiveTab(index),
9 role: 'tab',
10 'aria-selected': activeTab === index,
11 tabIndex: activeTab === index ? 0 : -1,
12 }),
13 getPanelProps: (index) => ({
14 role: 'tabpanel',
15 hidden: activeTab !== index,
16 }),
17 isActive: (index) => activeTab === index,
18 };
19}| Feature | Regular component | Headless Component | |---------|------------------|-------------------| | UI | Built-in styles/JSX | None -- YOU decide | | Reusability | Limited to one appearance | Unlimited | | Accessibility | Must be implemented manually | Built-in aria attributes | | Testability | Requires rendering | Test the hook alone |
The @headlessui/react library (from the creators of Tailwind CSS) provides ready-made headless components:
Listbox -- dropdown with full keyboard supportCombobox -- autocomplete/searchDialog -- modal with focus trapDisclosure -- accordion/expandableMenu -- dropdown menuTabs -- tab navigationEach of them provides logic, aria attributes, and keyboard handling, but zero styling. You provide all the appearance.
Headless Components are like a universal navigation system on a spaceship -- one control module that can be connected to any display panel. The logic is the same, but each panel looks different because every station has different needs!