Skip to content
Back to Blog
ReactHeadless ComponentsCustom HooksDesign PatternsComponent ArchitectureTypeScript

React Headless Components: Write the Logic Once. Own the Markup.

Headless components separate behavior and state from rendering, so the same dropdown logic can power a basic select, a combobox, and a command palette without duplicating a line of state management.

14 min read

TL;DR: A headless component is a custom hook that returns behavior and ARIA-ready prop getters, not rendered markup. The consumer spreads those props onto whatever elements they want. One hook can power a select, a combobox, and a command palette with zero duplicated logic. Libraries like Downshift, TanStack Table, and React Aria are built entirely on this pattern.


You finish a Dropdown. State handled, keyboard navigation wired, click-outside works, ARIA attributes in place. Clean component.

Then the design team wants a command palette. Same open/close behavior. Same arrow key navigation. Same escape-to-close. Completely different markup, different animation, different visual structure.

You have two choices: duplicate the logic, or figure out how to share it across different visual implementations. Most teams duplicate it. Then duplicate it again. By the third time, someone asks: why is all that behavior glued to specific HTML elements in the first place?

That question is where headless components come from.


Two Decisions Every Component Makes

Every React component makes two decisions: what it does, and what it looks like.

A Dropdown decides what it does: whether it's open, which item is selected, what happens when you press an arrow key, when to close on outside click. But it also decides what it looks like: the HTML structure, the CSS class names, the element type of the trigger, whether options are <li> or <div> elements.

Bundling both decisions in one component is fine for most apps. It becomes a problem when you need the first set of decisions to survive contact with a different design system, a different visual context, or a completely different use case.

Headless components make only the first set of decisions. The second set belongs to whoever is using the component.


What "Headless" Actually Means

Headless doesn't mean no output. It means no rendered output.

The pattern in modern React is a custom hook. Instead of a Dropdown component that renders specific HTML, you have a useDropdown hook that returns state, handlers, and ARIA-ready props you attach to whatever elements you want.

The consumer owns the markup. The hook owns the behavior.


The Naive Version (And Its Problem)

The first instinct is to just extract state:

// ❌ extracts state, but leaves the consumer to wire up behavior manually
function useDropdown(items: Item[]) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState<Item | null>(null);
 
  return { isOpen, setIsOpen, selectedItem, setSelectedItem };
}

This is state extraction, not headless. The consumer still has to remember to wire aria-expanded, handle the Escape key, set role="listbox" on the menu. You've moved state out of the component but kept all the behavior decisions inside the consumer.

Every team using this hook wires it up differently. Some add keyboard navigation. Some skip ARIA entirely. Six months later you have six similar-but-different dropdown implementations with six different bugs. The point of sharing behavior disappears.


Prop Getters: The Real Pattern

The proper headless hook returns prop getters: functions that produce the correct props for each UI role. The behavior travels with the props, not just the state.

type Item = { id: string; label: string };
 
function useDropdown<T extends Item>(items: T[], onSelect?: (item: T) => void) {
  const listboxId = useId();
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState<T | null>(null);
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
 
  const select = (item: T) => {
    setSelectedItem(item);
    setIsOpen(false);
    setHighlightedIndex(-1);
    onSelect?.(item);
  };
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setIsOpen(true);
        setHighlightedIndex((i) => Math.min(i + 1, items.length - 1));
        break;
      case "ArrowUp":
        e.preventDefault();
        setHighlightedIndex((i) => Math.max(i - 1, 0));
        break;
      case "Enter":
        if (highlightedIndex >= 0) select(items[highlightedIndex]);
        break;
      case "Escape":
        setIsOpen(false);
        setHighlightedIndex(-1);
        break;
    }
  };
 
  // for button triggers: click to toggle, keyboard to navigate
  const getTriggerProps = () => ({
    "aria-haspopup": "listbox" as const,
    "aria-expanded": isOpen,
    "aria-controls": listboxId,
    onClick: () => setIsOpen((o) => !o),
    onKeyDown: handleKeyDown,
  });
 
  // for input triggers: type to filter, keyboard to navigate
  const getInputProps = () => ({
    role: "combobox" as const,
    "aria-haspopup": "listbox" as const,
    "aria-expanded": isOpen,
    "aria-controls": listboxId,
    "aria-autocomplete": "list" as const,
    onKeyDown: handleKeyDown,
  });
 
  const getMenuProps = () => ({
    id: listboxId,
    role: "listbox" as const,
  });
 
  const getItemProps = (item: T, index: number) => ({
    role: "option" as const,
    "aria-selected": selectedItem?.id === item.id,
    onClick: () => select(item),
    onMouseEnter: () => setHighlightedIndex(index),
  });
 
  return {
    isOpen,
    setIsOpen,
    selectedItem,
    highlightedIndex,
    getTriggerProps,
    getInputProps,
    getMenuProps,
    getItemProps,
  };
}

A few things worth noticing. The hook is generic over T extends Item, so you can pass any object with id and label. useId() generates the listbox id used in aria-controls and id on the menu, wiring the two elements together for assistive tech. There are two trigger getters: getTriggerProps for button triggers that toggle on click, and getInputProps for input triggers that open on focus and type. The keyboard navigation lives in handleKeyDown and the right getter delivers it to whichever element owns focus.


One Hook, Three UIs

The same hook powers three different visual patterns. Each consumer decides its own structure. The keyboard behavior and ARIA are identical across all three.

Select

The classic dropdown: a button trigger, a floating list.

function SelectDropdown({ items }: { items: Item[] }) {
  const {
    isOpen,
    selectedItem,
    highlightedIndex,
    getTriggerProps,
    getMenuProps,
    getItemProps,
  } = useDropdown(items);
 
  return (
    <div className="select">
      <button className="select-trigger" {...getTriggerProps()}>
        {selectedItem?.label ?? "Select an option"}
        <ChevronIcon className={isOpen ? "rotate-180" : ""} />
      </button>
      {isOpen && (
        <ul className="select-list" {...getMenuProps()}>
          {items.map((item, i) => (
            <li
              key={item.id}
              className={[
                "select-option",
                i === highlightedIndex && "select-option--highlighted",
                item.id === selectedItem?.id && "select-option--selected",
              ]
                .filter(Boolean)
                .join(" ")}
              {...getItemProps(item, i)}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

getTriggerProps goes on the button. The button owns focus, so it owns onKeyDown. The menu gets the id so aria-controls on the button resolves correctly.

Combobox

An input that filters as you type. The input is the trigger. It owns focus the whole time.

function Combobox({ items, placeholder = "Search..." }: { items: Item[]; placeholder?: string }) {
  const [query, setQuery] = useState("");
  const filtered = useMemo(
    () =>
      items.filter((item) =>
        item.label.toLowerCase().includes(query.toLowerCase())
      ),
    [items, query]
  );
 
  const {
    isOpen,
    setIsOpen,
    selectedItem,
    highlightedIndex,
    getInputProps,
    getMenuProps,
    getItemProps,
  } = useDropdown(filtered);
 
  return (
    <div className="combobox">
      <input
        className="combobox-input"
        // show query while typing, selected label when closed
        value={isOpen ? query : (selectedItem?.label ?? "")}
        placeholder={placeholder}
        onChange={(e) => {
          setQuery(e.target.value);
          setIsOpen(true);
        }}
        onFocus={() => setIsOpen(true)}
        {...getInputProps()}
      />
      {isOpen && filtered.length > 0 && (
        <ul className="combobox-list" {...getMenuProps()}>
          {filtered.map((item, i) => (
            <li
              key={item.id}
              className={[
                "combobox-option",
                i === highlightedIndex && "combobox-option--highlighted",
              ]
                .filter(Boolean)
                .join(" ")}
              {...getItemProps(item, i)}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

getInputProps spreads role="combobox", aria-autocomplete="list", aria-controls, and onKeyDown onto the input. The user types, filtered updates, the list reflects it, but the keyboard navigation and ARIA come entirely from the hook.

Command Palette

A modal search interface. A button opens it, then a search input inside handles both filtering and keyboard navigation.

function CommandPalette({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const filtered = useMemo(
    () =>
      items.filter((item) =>
        item.label.toLowerCase().includes(query.toLowerCase())
      ),
    [items, query]
  );
 
  const {
    isOpen,
    setIsOpen,
    selectedItem,
    highlightedIndex,
    getInputProps,
    getMenuProps,
    getItemProps,
  } = useDropdown(filtered);
 
  return (
    <>
      <button className="palette-trigger" onClick={() => setIsOpen(true)}>
        {selectedItem?.label ?? "Open command palette"}
        <kbd>⌘K</kbd>
      </button>
      {isOpen && (
        <div className="palette-overlay" onClick={() => setIsOpen(false)}>
          <div className="palette" onClick={(e) => e.stopPropagation()}>
            <input
              className="palette-search"
              placeholder="Type a command..."
              value={query}
              autoFocus
              onChange={(e) => setQuery(e.target.value)}
              {...getInputProps()}
            />
            <ul className="palette-results" {...getMenuProps()}>
              {filtered.map((item, i) => (
                <li
                  key={item.id}
                  className={[
                    "palette-result",
                    i === highlightedIndex && "palette-result--active",
                  ]
                    .filter(Boolean)
                    .join(" ")}
                  {...getItemProps(item, i)}
                >
                  {item.label}
                </li>
              ))}
            </ul>
          </div>
        </div>
      )}
    </>
  );
}

The trigger button just calls setIsOpen(true): it doesn't need getTriggerProps because there's no list attached to it directly. The search input inside the palette gets getInputProps, so it handles arrow keys and Escape while staying focused throughout. Click-outside is a single onClick on the overlay with stopPropagation on the inner panel.

Three different UIs. Zero duplicated keyboard logic, zero duplicated ARIA.


More Patterns That Benefit

The dropdown is the canonical example. But the same problem shows up in almost every interactive component with more than one visual use case.

Toggle / Switch

A toggle looks simple: on or off. But the behavior includes keyboard support (Space to activate), role="switch", aria-checked, and controlled/uncontrolled modes. That's enough logic to be worth extracting when the same behavior needs to power a pill-shaped chip, a full feature card, and a traditional iOS-style switch.

// ❌ Toggle owns its markup: you can't reuse it as a chip or a card
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
  return (
    <button
      role="switch"
      aria-checked={checked}
      className={`toggle ${checked ? "toggle--on" : ""}`}
      onClick={() => onChange(!checked)}
      onKeyDown={(e) => {
        if (e.key === " ") {
          e.preventDefault();
          onChange(!checked);
        }
      }}
    >
      <span className="toggle-thumb" />
    </button>
  );
}

Need a "toggle chip"? You copy and paste the keyboard handler and ARIA attributes into the new component. Need a "toggle card"? Copy again. Every copy is another place for aria-checked to go missing.

// ✅ useToggle owns the behavior, the consumer owns the markup
function useToggle(checked: boolean, onChange: (v: boolean) => void) {
  const getToggleProps = () => ({
    role: "switch" as const,
    "aria-checked": checked,
    tabIndex: 0,
    onClick: () => onChange(!checked),
    onKeyDown: (e: React.KeyboardEvent) => {
      if (e.key === " ") {
        e.preventDefault();
        onChange(!checked);
      }
    },
  });
 
  return { checked, getToggleProps };
}

The traditional switch:

function ToggleSwitch({ checked, onChange }: ToggleProps) {
  const { getToggleProps } = useToggle(checked, onChange);
 
  return (
    <button className={`switch ${checked ? "switch--on" : ""}`} {...getToggleProps()}>
      <span className="switch-thumb" />
    </button>
  );
}

A pill-shaped filter chip with the same behavior:

function FilterChip({ label, checked, onChange }: FilterChipProps) {
  const { getToggleProps } = useToggle(checked, onChange);
 
  return (
    <span className={`chip ${checked ? "chip--active" : ""}`} {...getToggleProps()}>
      {checked && <CheckIcon className="chip-icon" />}
      {label}
    </span>
  );
}

A full feature card that highlights when selected:

function FeatureCard({ title, description, checked, onChange }: FeatureCardProps) {
  const { getToggleProps } = useToggle(checked, onChange);
 
  return (
    <div
      className={`feature-card ${checked ? "feature-card--selected" : ""}`}
      {...getToggleProps()}
    >
      <h3 className="feature-card-title">{title}</h3>
      <p className="feature-card-desc">{description}</p>
      <span className="feature-card-badge">{checked ? "Enabled" : "Disabled"}</span>
    </div>
  );
}

Three different visual treatments. Zero duplicated behavior. aria-checked and the keyboard handler come from the hook every time.


Tooltip

Tooltip behavior is more complex than it looks. Show on hover with a delay. Show immediately on keyboard focus (no delay). Hide on Escape. Wire aria-describedby on the trigger so screen readers announce the tooltip content. Most teams either skip all of this or bundle it into a <Tooltip> component with fixed markup.

// ❌ Tooltip is a floating bubble or nothing: can't render as an inline helper caption
function Tooltip({ content, children }: TooltipProps) {
  const [visible, setVisible] = useState(false);
  const id = useId();
 
  return (
    <div
      className="tooltip-wrapper"
      onMouseEnter={() => setVisible(true)}
      onMouseLeave={() => setVisible(false)}
      onFocus={() => setVisible(true)}
      onBlur={() => setVisible(false)}
    >
      {children}
      {visible && (
        <div role="tooltip" id={id} className="tooltip-bubble">
          {content}
        </div>
      )}
    </div>
  );
}

Now try to use that same logic for an inline helper caption below a form field. You can't. The structure is hardcoded to a floating div. The aria-describedby wiring lives buried inside the component and doesn't travel with you.

// ✅ useTooltip: behavior and ARIA packaged as props, visual output is yours
function useTooltip(delay = 200) {
  const [visible, setVisible] = useState(false);
  const id = useId();
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  const show = (withDelay: boolean) => {
    clearTimeout(timeoutRef.current);
    if (withDelay) {
      timeoutRef.current = setTimeout(() => setVisible(true), delay);
    } else {
      setVisible(true);
    }
  };
 
  const hide = () => {
    clearTimeout(timeoutRef.current);
    setVisible(false);
  };
 
  const getTriggerProps = () => ({
    "aria-describedby": visible ? id : undefined,
    onMouseEnter: () => show(true),
    onMouseLeave: hide,
    onFocus: () => show(false), // keyboard focus: no delay
    onBlur: hide,
    onKeyDown: (e: React.KeyboardEvent) => {
      if (e.key === "Escape") hide();
    },
  });
 
  const getTooltipProps = () => ({
    id,
    role: "tooltip" as const,
    hidden: !visible,
  });
 
  return { visible, getTriggerProps, getTooltipProps };
}

The classic floating bubble:

function TooltipBubble({ content, children }: { content: string; children: ReactNode }) {
  const { visible, getTriggerProps, getTooltipProps } = useTooltip();
 
  return (
    <span className="tooltip-host">
      <span {...getTriggerProps()}>{children}</span>
      {visible && (
        <div className="tooltip-bubble" {...getTooltipProps()}>
          {content}
        </div>
      )}
    </span>
  );
}

An inline helper caption below a form field, same hook, no floating positioning needed:

function FieldWithHelper({ label, helper, children }: FieldWithHelperProps) {
  const { visible, getTriggerProps, getTooltipProps } = useTooltip(0);
 
  return (
    <div className="field">
      <div className="field-label-row">
        <label>{label}</label>
        <button type="button" className="help-icon" {...getTriggerProps()}>
          ?
        </button>
      </div>
      {children}
      {visible && (
        <p className="field-helper" {...getTooltipProps()}>
          {helper}
        </p>
      )}
    </div>
  );
}

aria-describedby wired correctly in both. Delay logic in both. Escape to dismiss in both. The bubble and the inline caption look nothing alike, but they have identical accessibility behavior. That's what the hook is for.


The Libraries That Proved This

This isn't a niche pattern. Some of the most widely used libraries in the React ecosystem are built entirely on it.

Downshift: Accessible dropdown and combobox behavior. Returns prop getters for the toggle button, the menu, and each item. You write all the HTML. Downshift handles WAI-ARIA 1.1 compliance so you don't have to read the spec yourself.

TanStack Table: Returns a headless table model. You control every <table>, <thead>, <tr>, and <td>. TanStack handles sorting, filtering, pagination, and row selection. The table logic is completely decoupled from the DOM.

React Aria (Adobe): Hooks for every common UI primitive: buttons, dialogs, comboboxes, date pickers, sliders. Each hook returns props you apply to your own elements. Adobe's Spectrum design system is built on top of it. The hooks are used by a team with a specific design language; you can use the same hooks with a completely different one.

The common thread: these libraries separated the hardest, most spec-critical behavior from any HTML or CSS decisions. The behavior is the product. The HTML is yours.


Render Props: The Earlier Approach

Before hooks, headless components used render props:

// render props: works, but hooks are cleaner for most cases
<Dropdown items={items}>
  {({ isOpen, selectedItem, getTriggerProps, getMenuProps, getItemProps }) => (
    <div>
      <button {...getTriggerProps()}>{selectedItem?.label ?? "Select"}</button>
      {isOpen && (
        <ul {...getMenuProps()}>
          {items.map((item, i) => (
            <li key={item.id} {...getItemProps(item, i)}>
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )}
</Dropdown>

Same idea, different surface: behavior owned by the component, markup owned by the consumer. Hooks are the standard now. Less nesting, easier to compose with other hooks, simpler to test in isolation. Render props still have a place when you need the headless logic as a JSX node rather than a hook call, but for new components, hooks are the default.


When to Go Headless

Worth it when:

  • The same behavior needs to work with fundamentally different markup or styling systems. A component library used across multiple apps. A design system with multiple themes or platform targets.
  • Accessibility is non-negotiable and behavior needs to be encapsulated once. Dialogs, comboboxes, date pickers: the ARIA spec is complex enough that you don't want it scattered across 15 implementations.
  • The UI changes often but the behavior doesn't. Design rebrands, responsive overrides, theme switches: the logic stays stable while the presentation updates around it.

Not worth it when:

  • There's one design and one implementation. Don't abstract for hypothetical future requirements.
  • The component is simple. A styled <Button> doesn't need a useButton hook.
  • The behavior and markup are genuinely coupled. A canvas-based chart isn't going to benefit from a headless hook.

The test: if someone said "use this behavior with completely different HTML," would your current component make that easy or impossible?


The name is slightly misleading. Headless components don't lack output. They lack opinions about the output. The logic, state, and behavior are packaged and ready to attach to anything.

Once you start thinking about components this way, the separation becomes clear in both directions. You notice when rendering concerns are sneaking into a hook that should stay clean. You notice when you're re-implementing keyboard navigation for the third time in three different components.

The behavior is the hard part. Wrap it once and carry it anywhere.