We use cookies to enhance your experience on the site
CodeWorlds

Headless Components -- Separating logic from presentation

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.

What are Headless Components?

A Headless Component is a component or hook that:

  1. Manages state and logic (opening/closing, selection, navigation)
  2. Does NOT render any JSX -- has no appearance of its own
  3. Hands over UI control to the consumer through props, render props, or hooks
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}

Using the headless dropdown with any UI

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}

Headless Toggle / Disclosure

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}

Headless Tabs

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}

Why headless?

| 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 |

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 support
  • Combobox
    -- autocomplete/search
  • Dialog
    -- modal with focus trap
  • Disclosure
    -- accordion/expandable
  • Menu
    -- dropdown menu
  • Tabs
    -- tab navigation

Each of them provides logic, aria attributes, and keyboard handling, but zero styling. You provide all the appearance.

When to use headless components?

  • You're 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're creating components used across multiple projects -- same logic, different styles
  • You want to separate responsibilities -- hook = logic, component = 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!

Go to CodeWorlds