open atlas
↑ Back to track
React patterns, senior RXP · 03 · 02

Headless components

Headless components give you behavior, state, accessibility, and keyboard handling with zero styling or markup — you supply the UI — so you get fully custom looks without fighting a library; the failure mode is abstracting a one-look in-app widget.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

You need a combobox that matches a pixel-exact design. You reach for a component library and immediately start fighting it: overriding its CSS with !important, wrapping its markup to inject your own, patching its DOM structure to satisfy the designer. Two days later you have a styled component you don’t trust and didn’t fully build. Or you skip the library and hand-roll it — and now you own the keyboard arrow navigation, the active-descendant ARIA, the focus trap, the type-ahead, and the screen-reader announcements. Both roads are expensive.

A headless component is the third road. It ships the hard, invisible 80% — state, keyboard handling, focus management, ARIA wiring — and ships nothing visible. No styles, no markup opinions. You bring every <div>, every class, every pixel. You inherit correctness for free and pay zero in look-and-feel taxes.

Goal

After this lesson you can define a headless component as behavior + state + accessibility with no styling or markup; consume a headless primitive by spreading its prop-getters and state onto your own elements; explain why this is the modern answer to “reusable behavior, fully custom look” — you inherit keyboard, focus, and ARIA without fighting a library’s CSS; and name the failure mode — building a headless abstraction for a single in-app widget that has exactly one look is speculative generality.

1

Headless means the library owns behavior, you own the DOM. A styled component bundles three things: behavior (open/close, selection, keyboard), accessibility (roles, aria-*, focus), and presentation (markup + CSS). A headless component keeps the first two and hands you the third. It exposes its work as hooks, render props, or prop-getters — never as finished markup.

// A styled library: you get its <div>, its classes, its structure. You override.
<Combobox className="lib-combobox" /* now fight its internals */ />

// A headless library: it gives you behavior + a11y props; YOU write the elements.
const { getInputProps, getMenuProps, getItemProps, isOpen } = useCombobox({ items });
return (
  <div className="my-combobox">
    <input {...getInputProps()} className="my-input" />
    <ul {...getMenuProps()} className={isOpen ? "my-menu open" : "my-menu"}>
      {isOpen && items.map((item, i) => (
        <li key={item.id} {...getItemProps({ item, index: i })} className="my-row">
          {item.label}
        </li>
      ))}
    </ul>
  </div>
);

The library decides what the input and each row must announce and how arrow keys move selection; you decide what they look like and where they sit.

2

You inherit the accessibility and keyboard work that is genuinely hard to get right. This is the real payoff. A correct listbox needs role, aria-activedescendant, aria-expanded, aria-controls, roving focus, Home/End/PageUp/PageDown handling, type-ahead, and announcements — a multi-week project to do well, and a perennial source of bugs when hand-rolled. A headless primitive bakes this into its prop-getters, so spreading them onto your elements gives you a WAI-ARIA-compliant widget with your own skin.

// Radix is headless: unstyled, accessible primitives you compose and style yourself.
import * as Dialog from "@radix-ui/react-dialog";

export function ConfirmDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger className="btn">Delete</Dialog.Trigger>
      <Dialog.Portal>
        {/* focus trap, Esc-to-close, scroll lock, aria-modal, restore-focus: all free */}
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="card">
          <Dialog.Title className="title">Delete project?</Dialog.Title>
          <Dialog.Close className="btn-danger">Confirm</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Every className is yours. Every focus trap, Esc handler, and aria-modal is the library’s. You did not write the part most teams get wrong.

3

Custom look has no cost, because there are no styles to override. With a styled kit, customization is subtraction — you remove and override what shipped. With headless, customization is the default state: nothing ships styled, so you never reverse-engineer a library’s CSS specificity or shadow-DOM. The same headless useReactTable powers a dense admin grid and an airy marketing table; the behavior is shared, the markup diverges completely.

import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table";

const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });

// TanStack Table is headless: it gives you the row model + state, no <table> at all.
return (
  <table className="my-grid">
    <tbody>
      {table.getRowModel().rows.map((row) => (
        <tr key={row.id} className="my-row">
          {row.getVisibleCells().map((cell) => (
            <td key={cell.id} className="my-cell">
              {flexRender(cell.column.columnDef.cell, cell.getContext())}
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  </table>
);

Sorting, filtering, pagination, grouping — all behavior, zero markup. You render whatever the design demands.

4

Failure mode: a headless abstraction for one in-app widget with exactly one look is speculative generality. Headless earns its keep when behavior is shared but appearance varies — across an app, a design system, or a library’s many consumers. If you have a single dropdown used in one place with one design, building your own useDropdown headless layer (prop-getters, generic state, render props) buys you nothing: there is no second look to vary, no third consumer to serve. You’ve paid the abstraction’s full cost — indirection, a generic API, more files — for a flexibility you will never use.

// Over-engineering: a homegrown headless layer for ONE component with ONE look.
function useDropdown<T>() { /* prop-getters, generic state, a render-prop API… */ }
function AppMenu() {
  const { getToggleProps, getItemProps, isOpen } = useDropdown<MenuItem>();
  // …used in exactly one place, styled exactly one way, forever
}

// Senior choice: a plain component. Reach for headless only when variation is real.
function AppMenu() {
  const [open, setOpen] = useState(false);
  return /* the one markup this app needs */;
}

Consuming a published headless library (Radix, Headless UI) is cheap and almost always right — someone already paid the abstraction cost and the a11y is battle-tested. Authoring your own headless layer is only worth it once you genuinely have multiple looks over one behavior.

Worked example

Same tabs widget, two builds — watch the headless seam remove the work you’d otherwise own. A design needs tabs with a fully custom underline animation and bespoke spacing.

Build A hand-rolls everything, including the accessibility nobody remembers until an audit:

function Tabs({ tabs }: { tabs: Tab[] }) {
  const [active, setActive] = useState(0);
  return (
    <div className="tabs">
      {/* No role="tablist", no aria-selected, no arrow-key navigation,
          no aria-controls, no roving tabindex — keyboard + SR users are stranded */}
      <div className="tabrow">
        {tabs.map((t, i) => (
          <button key={t.id} className={i === active ? "tab on" : "tab"} onClick={() => setActive(i)}>
            {t.label}
          </button>
        ))}
      </div>
      <div className="panel">{tabs[active].content}</div>
    </div>
  );
}

It looks right and ships broken for keyboard and screen-reader users. Build B consumes a headless primitive (Headless UI / Radix Tabs) and supplies only markup and classes:

import { Tab } from "@headlessui/react";

function Tabs({ tabs }: { tabs: Tab[] }) {
  return (
    <Tab.Group> {/* arrow-key nav, roving tabindex, role+aria-selected: all included */}
      <Tab.List className="tabrow">
        {tabs.map((t) => (
          <Tab key={t.id} className={({ selected }) => (selected ? "tab on" : "tab")}>
            {t.label} {/* your underline animation lives entirely in your CSS */}
          </Tab>
        ))}
      </Tab.List>
      <Tab.Panels>
        {tabs.map((t) => <Tab.Panel key={t.id} className="panel">{t.content}</Tab.Panel>)}
      </Tab.Panels>
    </Tab.Group>
  );
}

B has the identical custom look as A — the underline, the spacing, every class is yours — but the keyboard navigation and ARIA are correct because the headless library owns them. You wrote the 20% that’s design-specific and inherited the 80% that’s error-prone. The cost (a dependency, a small API to learn) is paid by the library’s author once, for every consumer. That is exactly when reaching for headless is the senior choice — and exactly why hand-rolling your own headless layer for one in-app look is not.

Why this works

Why has headless become the dominant answer to “reusable behavior, custom look”? Because the two older answers each fail at scale. Fully-styled kits (old Bootstrap, MUI defaults) give you speed but trap you in their look; escaping it means an override war against their CSS that compounds with every upgrade. Hand-rolling gives you full control but re-implements the hard, invisible accessibility layer in every project, badly. Headless splits the difference cleanly: the part that is the same everywhere and hard to get right (keyboard, focus, ARIA, state) is shared and battle-tested; the part that is different everywhere and easy (markup, CSS) stays fully yours. Radix, Headless UI, TanStack Table, Downshift, and React Aria all sell exactly this seam.

Common mistake

The trap isn’t consuming headless libraries — that’s almost always right. The trap is authoring a headless abstraction prematurely. A team reads about prop-getters and render props, gets excited, and wraps their single in-app <Sidebar> or <Modal> in a generic useDisclosure + render-prop API “so it’s reusable”. But it’s used once, styled once, and the abstraction makes the code harder to read for a reuse that never comes. The rule: consume published headless primitives freely; only build your own headless layer once you have at least two real, differently-styled consumers of the same behavior. Until then, a plain component is the senior choice.

Check yourself
Quiz

Your app has one settings dropdown, used on a single screen, with exactly one design that will never vary. A teammate proposes building a generic useDropdown headless hook (prop-getters, render-prop API) 'for reuse'. What's the senior call?

Recap

A headless component ships behavior, state, accessibility, and keyboard/focus handling with zero styling or markup — it exposes its work through hooks, render props, or prop-getters and lets you bring every element and class yourself. That seam is the modern answer to “reusable behavior, fully custom look”: you inherit the hard, error-prone 80% (roving focus, aria-*, type-ahead, focus traps) without fighting a library’s CSS, and you own the easy, design-specific 20% (markup + pixels) completely. Consuming a published headless primitive — Radix, Headless UI, TanStack Table — is cheap and almost always the senior choice, because someone already paid the abstraction cost and the a11y is battle-tested. Authoring your own headless layer is the opposite call: doing it for a single in-app widget with exactly one look is speculative generality — you pay full price in indirection and a generic API for a flexibility you’ll never use. The rule that separates the two: headless earns its keep only when one behavior must wear many looks. Until that variation is real, reach for a plain component.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.