Skip to content

Headless Components and Hooks

advanced15 min read

The Styling Problem

You build a beautiful dropdown. Custom styles, animations, perfect spacing. Then a new project starts with a different design system. Your dropdown is useless because the styling is baked in. You build another one.

This is the core tension: behavior (keyboard navigation, focus management, ARIA attributes, state logic) is universal. Styling is not. Headless components solve this by shipping only the behavior and letting consumers bring their own styles.

Mental Model

Think of headless components like a car engine sold without the body. The engine (behavior) works with any chassis (styling) you bolt it into. A sedan, an SUV, a sports car — same engine, completely different appearance. Headless components are the engine. Your design system is the body.

The Headless Pattern

A headless component provides:

  • State management (open/closed, selected/unselected, focused item)
  • Keyboard interactions (arrow keys, Enter, Escape, Home/End)
  • ARIA attributes (roles, aria-expanded, aria-selected, aria-activedescendant)
  • Focus management (trap focus, restore focus, roving tabindex)

It does NOT provide:

  • Any HTML structure (you choose the elements)
  • Any CSS (you bring your own styles)
  • Any animations (you add them yourself)
// STYLED component — beautiful but inflexible
<Select options={options} value={selected} onChange={setSelected} />

// HEADLESS component — behavior only, you own the rendering
<Select value={selected} onValueChange={setSelected}>
  <SelectTrigger>
    <SelectValue placeholder="Pick a language" />
  </SelectTrigger>
  <SelectContent>
    {languages.map((lang) => (
      <SelectItem key={lang.value} value={lang.value}>
        <LangIcon name={lang.icon} />
        <span>{lang.label}</span>
        {lang.popular && <Badge>Popular</Badge>}
      </SelectItem>
    ))}
  </SelectContent>
</Select>

The headless version gives you full control over what each item looks like, what extra elements you include, and how everything is styled. The keyboard navigation and ARIA roles come for free.

Quiz
What is the primary benefit of headless components over styled component libraries?

Custom Hooks as Headless Components

Before Radix and React Aria existed, the headless pattern lived in custom hooks. A hook encapsulates behavior and returns props you spread onto your elements.

function useToggle(defaultPressed = false) {
  const [pressed, setPressed] = useState(defaultPressed);

  const buttonProps = {
    role: "switch" as const,
    "aria-checked": pressed,
    onClick: () => setPressed((p) => !p),
    onKeyDown: (e: KeyboardEvent) => {
      if (e.key === " " || e.key === "Enter") {
        e.preventDefault();
        setPressed((p) => !p);
      }
    },
  };

  return { pressed, setPressed, buttonProps };
}

// Usage — consumer owns all the styling
function DarkModeToggle() {
  const { pressed, buttonProps } = useToggle();

  return (
    <button
      {...buttonProps}
      className={cn(
        "relative h-8 w-14 rounded-full transition-colors",
        pressed ? "bg-indigo-600" : "bg-zinc-300",
      )}
    >
      <span
        className={cn(
          "absolute top-1 left-1 h-6 w-6 rounded-full bg-white transition-transform",
          pressed && "translate-x-6",
        )}
      />
    </button>
  );
}

The hook handles the ARIA role, checked state, and keyboard interaction. The consumer decides it looks like a sliding toggle with Tailwind classes. If tomorrow the design changes to a checkbox style, only the JSX changes. The hook stays identical.

Building a Headless Combobox

Let us build something real. A combobox (autocomplete input) is one of the hardest UI patterns to get right. The behavior is complex: filtering, keyboard navigation, screen reader announcements, focus management.

interface UseComboboxOptions<T> {
  items: T[];
  itemToString: (item: T) => string;
  onSelectedItemChange?: (item: T | null) => void;
}

function useCombobox<T>({ items, itemToString, onSelectedItemChange }: UseComboboxOptions<T>) {
  const [isOpen, setIsOpen] = useState(false);
  const [inputValue, setInputValue] = useState("");
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
  const [selectedItem, setSelectedItem] = useState<T | null>(null);
  const listboxId = useId();

  const filteredItems = items.filter((item) =>
    itemToString(item).toLowerCase().includes(inputValue.toLowerCase()),
  );

  function selectItem(item: T) {
    setSelectedItem(item);
    setInputValue(itemToString(item));
    setIsOpen(false);
    setHighlightedIndex(-1);
    onSelectedItemChange?.(item);
  }

  const inputProps = {
    role: "combobox" as const,
    "aria-expanded": isOpen,
    "aria-controls": listboxId,
    "aria-activedescendant":
      highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined,
    "aria-autocomplete": "list" as const,
    value: inputValue,
    onChange: (e: ChangeEvent<HTMLInputElement>) => {
      setInputValue(e.target.value);
      setIsOpen(true);
      setHighlightedIndex(0);
    },
    onKeyDown: (e: KeyboardEvent) => {
      switch (e.key) {
        case "ArrowDown":
          e.preventDefault();
          setIsOpen(true);
          setHighlightedIndex((i) => Math.min(i + 1, filteredItems.length - 1));
          break;
        case "ArrowUp":
          e.preventDefault();
          setHighlightedIndex((i) => Math.max(i - 1, 0));
          break;
        case "Enter":
          e.preventDefault();
          if (highlightedIndex >= 0 && filteredItems[highlightedIndex]) {
            selectItem(filteredItems[highlightedIndex]);
          }
          break;
        case "Escape":
          setIsOpen(false);
          setHighlightedIndex(-1);
          break;
      }
    },
    onFocus: () => setIsOpen(true),
    onBlur: () => {
      setTimeout(() => setIsOpen(false), 150);
    },
  };

  function getItemProps(index: number) {
    return {
      id: `${listboxId}-option-${index}`,
      role: "option" as const,
      "aria-selected": index === highlightedIndex,
      onClick: () => selectItem(filteredItems[index]),
      onMouseEnter: () => setHighlightedIndex(index),
    };
  }

  const listboxProps = {
    id: listboxId,
    role: "listbox" as const,
  };

  return {
    isOpen,
    inputValue,
    highlightedIndex,
    selectedItem,
    filteredItems,
    inputProps,
    listboxProps,
    getItemProps,
  };
}

Simplified for teaching — a production combobox also needs Home/End keys (jump to first/last item), Page Up/Down (scroll by page), and type-ahead search. Libraries like Downshift and React Aria handle all of these.

Now the consumer can render this however they want:

function LanguagePicker({ languages }: { languages: Language[] }) {
  const {
    isOpen,
    highlightedIndex,
    filteredItems,
    inputProps,
    listboxProps,
    getItemProps,
  } = useCombobox({
    items: languages,
    itemToString: (lang) => lang.name,
  });

  return (
    <div className="relative">
      <input {...inputProps} className="w-full rounded-lg border px-4 py-2" />
      {isOpen && filteredItems.length > 0 && (
        <ul {...listboxProps} className="absolute mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg">
          {filteredItems.map((lang, index) => (
            <li
              key={lang.id}
              {...getItemProps(index)}
              className={cn(
                "flex cursor-pointer items-center gap-2 px-4 py-2",
                index === highlightedIndex && "bg-indigo-50",
              )}
            >
              <span className="text-xl">{lang.flag}</span>
              <span>{lang.name}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
Common Trap

The onBlur handler uses setTimeout to delay closing. Without this, clicking an option triggers onBlur before onClick, closing the listbox before the click registers. This race condition is one of the most common bugs in custom combobox implementations. Production libraries like Downshift use onMouseDown with preventDefault instead, which is more robust.

Quiz
In the headless combobox, what is the purpose of aria-activedescendant on the input?

Radix UI and React Aria

You rarely need to build headless components from scratch. Three libraries dominate this space:

FeatureRadix UIReact Aria (Adobe)Base UI (MUI)
ApproachCompound components with ContextHooks that return props to spreadRender props for full control
StylingBring your own CSS/TailwindBring your own CSS/TailwindBring your own CSS/Tailwind
ARIA complianceBuilt-in, follows WAI-ARIA APGBuilt-in, follows WAI-ARIA APGBuilt-in, follows WAI-ARIA APG
AnimationsData attributes for CSS transitionsHooks for animation statesData attributes + CSS hooks
Bundle sizePer-component imports, tree-shakeablePer-hook imports, tree-shakeablePer-component imports, tree-shakeable
PolymorphismasChild with Slot componentelementType proprender prop
Server ComponentsClient-only (interactive)Client-only (interactive)Client-only (interactive)
ExtrasPrimitives focused on webToast (alpha), Autocomplete, Virtualizer, Tree with DnDUsed by shadcn/ui, backed by MUI team

Radix uses compound components:

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="ghost">Options</Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent>
    <DropdownMenuItem onSelect={() => edit()}>Edit</DropdownMenuItem>
    <DropdownMenuItem onSelect={() => duplicate()}>Duplicate</DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem onSelect={() => remove()} className="text-red-500">
      Delete
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

React Aria uses hooks:

function MyDropdownMenu({ items }: MenuProps) {
  const state = useMenuTriggerState({});
  const ref = useRef(null);
  const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);

  return (
    <>
      <Button {...menuTriggerProps} ref={ref}>Options</Button>
      {state.isOpen && (
        <Popover state={state}>
          <Menu {...menuProps} items={items}>
            {(item) => <MenuItem>{item.name}</MenuItem>}
          </Menu>
        </Popover>
      )}
    </>
  );
}

Base UI uses render props — you pass a render prop to swap the underlying element:

import { Button } from "@base-ui-components/react/button";
import Link from "next/link";

function MyNavButton() {
  return (
    <Button render={<Link href="/dashboard" />}>
      Dashboard
    </Button>
  );
}

All three give you full accessibility. Radix is the most ergonomic for most use cases. React Aria gives you finer control when you need to compose behaviors in unusual ways. Base UI (v1.0 stable since Dec 2025) sits between the two — render props are more explicit than asChild but less boilerplate than hooks.

Quiz
When should you build a custom headless component instead of using Radix UI or React Aria?

The Accessibility Guarantee

Here is the thing most people miss about headless components: the accessibility is not an add-on. It is the whole point.

Building an accessible dropdown from scratch requires handling:

  • 15+ keyboard interactions (ArrowDown, ArrowUp, Home, End, Enter, Space, Escape, type-ahead, Tab)
  • 8+ ARIA attributes (role, aria-expanded, aria-haspopup, aria-controls, aria-activedescendant, aria-selected, aria-disabled, aria-label)
  • Focus management (restore focus on close, trap focus in submenus)
  • Screen reader announcements (live regions for dynamic content)

That is months of work to do correctly. A headless library gives you all of it by default. Using Radix or React Aria is not laziness — it is the responsible engineering choice.

Key Rules
  1. 1Use headless libraries (Radix, React Aria, Base UI) for standard patterns — dropdown, dialog, tabs, combobox, tooltip
  2. 2Build custom headless hooks only for genuinely novel interactions
  3. 3Always test with keyboard and screen reader — headless components make this easier, not optional
  4. 4Headless does not mean unstyled — it means behavior-first with full styling freedom
What developers doWhat they should do
Building a custom dropdown from div and onClick handlers
Custom dropdown implementations almost always fail keyboard navigation, screen reader announcements, and focus restoration. The bugs are subtle and affect real users.
Use Radix DropdownMenu or React Aria useMenu — they handle 50+ edge cases you will miss
Choosing a styled component library like MUI then overriding every style
Overriding opinionated styles means fighting specificity, understanding the library's internal class structure, and dealing with version upgrades that change class names. Starting headless avoids this entirely.
Choose a headless library and style from scratch — you will fight less
Interview Question

You need to build an accessible combobox with multi-select, tag display, and async search. Walk through how you would architect this. Would you use a headless library or build from scratch? What accessibility concerns would you prioritize? How would you handle the keyboard interaction model for removing tags?