JavaScript and React course Β· Module 15: Patterns and Architecture
Headless Components - Separating logic from presentation
In this lesson7
The cockpit and the engineering panel need the same dropdown: opening, closing, picking an option, keyboard handling and accessibility attributes. The cockpit, however, wants a simple button, while the engineers want a space menu with icons. Two components mean two copies of tricky logic and two places for bugs. A universal control module that you can plug into any panel is a better idea. That is exactly the idea behind Headless Components: components that contain all the logic but zero UI.
What are Headless Components?
A Headless Component is a component or hook that:
- Manages state and logic (opening/closing, selection, navigation)
- Does NOT render any JSX, meaning it has no appearance of its own
- Hands control over the UI to the consumer through props, render props or hooks
Most often it is simply a custom hook, like useCrew from the previous lesson, except that instead of API data it manages the behavior of the interface. Let's build a headless dropdown step by step. First, the list state and three simple actions:
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 };None of this appears on the screen yet: it is pure logic that will work with any appearance. Now the keyboard handling, the part most often skipped when writing a dropdown from scratch:
1 // Keyboard navigation
2 const handleKeyDown = (e) => {
3 if (e.key === 'ArrowDown') {
4 e.preventDefault();
5 setSelectedIndex(prev => Math.min(prev + 1, options.length - 1));
6 } else if (e.key === 'ArrowUp') {
7 e.preventDefault();
8 setSelectedIndex(prev => Math.max(prev - 1, 0));
9 } else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) {
10 e.preventDefault();
11 select(selectedIndex);
12 } else if (e.key === 'Escape') {
13 close();
14 }
15 };The arrows move the index within the bounds of the list, Enter confirms the choice and Escape closes the list. e.preventDefault() switches off the browser's default reactions: the arrows do not scroll the page, and Enter does not add a regular button click that would immediately open the list again. A closed list is opened by exactly that click, because on a button Enter and Space work like a mouse click. In this simplified version a single index serves both for highlighting and for selection, while libraries keep those two things apart. Finally, the hook returns everything the view needs, including two functions that return ready-made sets of props:
1 return {
2 isOpen, selectedIndex, selectedOption,
3 toggle, close, select, handleKeyDown,
4 getToggleProps: () => ({
5 onClick: toggle,
6 onKeyDown: handleKeyDown,
7 'aria-expanded': isOpen,
8 'aria-haspopup': 'listbox',
9 }),
10 getOptionProps: (index) => ({
11 onClick: () => select(index),
12 'aria-selected': selectedIndex === index,
13 role: 'option',
14 }),
15 };
16}getToggleProps() hands over onClick, onKeyDown, aria-expanded and aria-haspopup, while getOptionProps(index) gives each option a role and aria-selected. The consumer spreads these objects with the spread operator and does not have to remember about accessibility. Functions like these are called prop getters, and you will find them in the Downshift library, for example.
Using the headless dropdown with any UI
The key to headless components: the same hook, different looks. The first version is a plain button and list, without any decorations:
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}The second version uses the identical hook, but builds a space menu with icons and an arrow that shows the state of the list:
1// Version 2: Space dropdown with icons
2function SpaceDropdown({ options, icons }) {
3 const dropdown = useDropdown(options);
4
5 return (
6 <div className="space-dropdown">
7 <button type="button" {...dropdown.getToggleProps()} className="space-trigger">
8 <span>{dropdown.selectedOption || 'Select system'}</span>
9 <span>{dropdown.isOpen ? 'β²' : 'βΌ'}</span>
10 </button>
11 {dropdown.isOpen && (
12 <div className="space-menu">
13 {options.map((opt, i) => (
14 <div key={i} {...dropdown.getOptionProps(i)} className="space-option">
15 <span>{icons[i]}</span>
16 <span>{opt}</span>
17 </div>
18 ))}
19 </div>
20 )}
21 </div>
22 );
23}The two components differ only in their JSX, and the hook did not change by a single line. You can also destructure the result right away: const { isOpen, selectedOption, getToggleProps, getOptionProps } = useDropdown(options). Notice that the trigger is a <button> again, even though it looks completely different from the first version. The hook supplies the ARIA attributes, but you choose the element: a <div> would not receive keyboard focus, and even with tabIndex={0} and role="button" you would have to handle Enter and Space yourself.
Headless Toggle / Disclosure
Another example is a headless toggle for building accordions, expandable sections and spoilers. This hook is short, because it looks after just one boolean value:
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}We will build two completely different components on the same toggle. The first is an accordion that expands the description of a ship system:
1// Usage 1: Accordion
2function SystemDetails({ title, children }) {
3 const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5 return (
6 <div className="accordion-item">
7 <button {...getToggleProps()}>
8 {title} {isOpen ? 'β' : '+'}
9 </button>
10 <div {...getPanelProps()}>
11 {isOpen && children}
12 </div>
13 </div>
14 );
15}The tooltip looks different, but it uses the identical hook API, so the opening and closing logic is not duplicated:
1// Usage 2: Tooltip
2function InfoTooltip({ content, children }) {
3 const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5 return (
6 <span className="tooltip-wrapper">
7 <button type="button" {...getToggleProps()} className="tooltip-trigger">
8 {children}
9 </button>
10 <span {...getPanelProps()} className="tooltip-content">
11 {isOpen && content}
12 </span>
13 </span>
14 );
15}getPanelProps() sets hidden and role="region": thanks to hidden, a collapsed panel disappears for screen readers too, not just visually. The trigger is a <button> again, because aria-expanded is only allowed on elements with a role, such as a button, and a <span> would not receive focus. You will find the same API shape in headless libraries under the name Disclosure.
Headless Tabs
Tabs are the third classic. The hook remembers the active tab and assigns the tab and tabpanel roles, which screen readers understand without extra descriptions:
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}A tabIndex of 0 only for the active tab is the roving tabindex technique: the Tab key lands straight on the active tab. The WAI-ARIA tabs pattern also adds switching with the arrow keys, and that is where the currently unused tabCount parameter will come in handy.
Why headless?
| Feature | Regular component | Headless Component |
|---|---|---|
| UI | Built-in styles/JSX | Zero, YOU decide |
| Reusability | Limited to one look | Unlimited |
| Accessibility | Must be implemented manually | Built-in aria attributes |
| Testability | Requires rendering | Test the hook itself |
Headless UI in the React ecosystem
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: tabs
Each of them provides logic, aria attributes and keyboard support, but zero styling. You provide the entire appearance. Radix UI Primitives and React Aria from Adobe work in a similar way.
When to use headless components?
- You are building a design system: different teams need the same interactions but different styles
- You need full accessibility (a11y): headless components have built-in aria attributes
- You are creating components used across many projects: same logic, different styles
- You want to separate responsibilities: hook = logic, component = appearance
Headless Components work like a ship's universal navigation system: the logic is the same, but every panel looks different, because every station has different needs. My advice: write your own headless hook for simple toggles like useToggle. A dropdown, combobox or dialog with full keyboard and focus handling is a lot of work, so reach for a proven library there. In the next lesson we will give the component's user even more control with Inversion of Control, and you will see how prop getters differ from a props collection.
Remember: a headless hook is an engine without a hull, and you design the hull yourself.
Code for this lesson: App.jsx
1import React, { useState } from 'react';
2
3// === HEADLESS HOOK: useDropdown ===
4function useDropdown(options) {
5 const [isOpen, setIsOpen] = useState(false);
6 const [selectedIndex, setSelectedIndex] = useState(-1);
7 const [selectedOption, setSelectedOption] = useState(null);
8
9 const toggle = () => setIsOpen(p => !p);
10 const close = () => setIsOpen(false);
11
12 const select = (index) => {
13 setSelectedIndex(index);
14 setSelectedOption(options[index]);
15 setIsOpen(false);
16 };
17
18 // preventDefault: the arrows do not scroll the page, and Enter does not add a click that would reopen the list
19 const handleKeyDown = (e) => {
20 if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, options.length - 1)); }
21 else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)); }
22 else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) { e.preventDefault(); select(selectedIndex); }
23 else if (e.key === 'Escape') close();
24 };
25
26 return {
27 isOpen, selectedIndex, selectedOption, toggle, close, select, handleKeyDown,
28 getToggleProps: () => ({ onClick: toggle, onKeyDown: handleKeyDown, 'aria-expanded': isOpen, 'aria-haspopup': 'listbox' }),
29 getOptionProps: (i) => ({ onClick: () => select(i), 'aria-selected': selectedIndex === i, role: 'option' }),
30 };
31}
32
33// === HEADLESS HOOK: useToggle ===
34function useToggle(initial = false) {
35 const [isOpen, setIsOpen] = useState(initial);
36 return {
37 isOpen,
38 toggle: () => setIsOpen(p => !p),
39 getToggleProps: () => ({ onClick: () => setIsOpen(p => !p), 'aria-expanded': isOpen }),
40 getPanelProps: () => ({ role: 'region', hidden: !isOpen }),
41 };
42}
43
44// === VISUAL COMPONENT 1: Simple Dropdown ===
45function SimpleDropdown({ options, label }) {
46 const dd = useDropdown(options);
47 return (
48 <div className="dropdown">
49 <button className="dd-trigger" {...dd.getToggleProps()}>
50 {dd.selectedOption || label} <span>{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
51 </button>
52 {dd.isOpen && (
53 <ul className="dd-menu" role="listbox">
54 {options.map((opt, i) => (
55 <li key={i} className={"dd-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
56 {opt}
57 </li>
58 ))}
59 </ul>
60 )}
61 </div>
62 );
63}
64
65// === VISUAL COMPONENT 2: Space Dropdown (different look, same logic) ===
66function SpaceDropdown({ options, icons, label }) {
67 const dd = useDropdown(options);
68 return (
69 <div className="space-dd">
70 <button type="button" className="space-trigger" {...dd.getToggleProps()}>
71 <span>{dd.selectedOption || label}</span>
72 <span className="arrow">{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
73 </button>
74 {dd.isOpen && (
75 <div className="space-menu">
76 {options.map((opt, i) => (
77 <div key={i} className={"space-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
78 <span className="opt-icon">{icons[i]}</span>
79 <span>{opt}</span>
80 </div>
81 ))}
82 </div>
83 )}
84 </div>
85 );
86}
87
88// === Accordion using useToggle ===
89function AccordionItem({ title, children }) {
90 const { isOpen, getToggleProps, getPanelProps } = useToggle();
91 return (
92 <div className="accordion-item">
93 <button className="accordion-header" {...getToggleProps()}>
94 {title} <span>{isOpen ? '\u2212' : '+'}</span>
95 </button>
96 <div className="accordion-body" {...getPanelProps()}>
97 {isOpen && <div className="accordion-content">{children}</div>}
98 </div>
99 </div>
100 );
101}
102
103// === MAIN ===
104export default function App() {
105 return (
106 <div className="app">
107 <h1>Headless Components Demo</h1>
108 <p className="subtitle">Same logic hooks, different visual implementations</p>
109
110 <div className="section">
111 <h3>Simple Dropdown (useDropdown)</h3>
112 <SimpleDropdown options={['Mars', 'Jupiter', 'Saturn', 'Europa']} label="Select planet..." />
113 </div>
114
115 <div className="section">
116 <h3>Space Dropdown (same useDropdown, different UI)</h3>
117 <SpaceDropdown
118 options={['Engines', 'Shields', 'Navigation', 'Weapons']}
119 icons={['ENG', 'SHD', 'NAV', 'WPN']}
120 label="Select system..."
121 />
122 </div>
123
124 <div className="section">
125 <h3>Accordion (useToggle)</h3>
126 <AccordionItem title="Engine Status">
127 <p>Plasma drive operating at 94% efficiency.</p>
128 </AccordionItem>
129 <AccordionItem title="Shield Report">
130 <p>Forward shields at 78%. Rear shields at 100%.</p>
131 </AccordionItem>
132 <AccordionItem title="Navigation">
133 <p>Course set for Alpha Centauri. ETA: 4.2 years.</p>
134 </AccordionItem>
135 </div>
136 </div>
137 );
138}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What are Headless Components in the context of React?
2. What is the purpose of getToggleProps() and getOptionProps() methods in headless hooks?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Finish two headless hooks and the two looks built on them. In useDropdown ___BLANK1___: after an option is selected the list closes (use the ready close action). ___BLANK2___: getToggleProps sets aria-expanded according to whether the list is open. ___BLANK3___: getOptionProps(index) sets aria-selected to true only for the selected option (selectedIndex) and to false for the others. In useToggle ___BLANK4___: getPanelProps hides the panel with the hidden attribute when the toggle is closed. ___BLANK5___: SimpleDropdown spreads on its button the props returned by getToggleProps of the useDropdown hook (the result of the call, not the function itself).
- Horizontal ordering
Arrange the destructuring syntax for the useDropdown headless hook result:
- Click in order
Click the steps for creating a headless component in order: