Frontend / Component libraries / 01_headless_ui_and_radix.md

Headless UI and Radix

Updated 4 interview angles 4 min read source
On this page10
  1. What is a “headless” component?
  2. Why use one instead of hand-rolling?
  3. What’s Radix’s asChild pattern?
  4. Controlled vs uncontrolled in these libraries?
  5. Radix vs Headless UI vs React Aria?
  6. How do you style a headless component?
  7. Gotchas
  8. Related
  9. Further reading
  10. Interview angle

Headless UI and Radix

Headless (unstyled) component libraries give you behavior, state, keyboard interaction, and accessibility — and zero styling. You bring the look (Tailwind, CSS, whatever); they bring the hard parts. Radix Primitives, React Aria (Adobe), Headless UI (Tailwind Labs), and Ark UI are the main options. The senior framing: the behavior of widgets like comboboxes and dialogs is genuinely hard and easy to get wrong (see Accessible Components (APG Patterns)), so separate behavior (library) from presentation (you).

What is a “headless” component?

A component that implements logic and accessibility but renders no styles (and often minimal/neutral markup). It exposes state and props so you control the DOM/CSS. Contrast with “styled” libraries (MUI, Ant, Chakra) that ship opinionated visuals. Headless = maximum design control + correct a11y; you’re not fighting someone else’s CSS.

Why use one instead of hand-rolling?

Because correct widget behavior is a long tail of detail: focus trapping/restoration, roving tabindex, aria-activedescendant, type-ahead, collision-aware positioning, Escape handling, screen-reader announcements, RTL, touch. A vetted primitive has solved and regression-tested all of it. You hand-roll a toggle; you do not hand-roll a production combobox.

What’s Radix’s asChild pattern?

Instead of rendering its own element, a Radix primitive can merge its props/behavior onto your child via asChild (powered by a Slot component). This avoids wrapper-element bloat and lets you use your own component/element:

tsx
import * as Dialog from "@radix-ui/react-dialog";

<Dialog.Trigger asChild>
  <MyButton>Open</MyButton>   {/* Radix merges trigger props onto MyButton instead of rendering its own <button> */}
</Dialog.Trigger>

It’s an alternative to the as prop (Polymorphic Components (the as prop)) — composition by merging onto a single child rather than a polymorphic prop.

Controlled vs uncontrolled in these libraries?

Good primitives support both: uncontrolled with defaultValue/defaultOpen (the component owns state) or controlled with value/open + onValueChange/onOpenChange (you own it). Same controlled/uncontrolled model as native inputs (Controlled vs uncontrolled components) — use uncontrolled until you need to drive/observe the state.

Radix vs Headless UI vs React Aria?

Radix Primitives Headless UI React Aria (Adobe)
Style components (<Dialog.Root>…) components hooks (useButton, useComboBox)
Coverage broad, composable smaller, Tailwind-aligned broadest behavior + i18n/RTL
Ergonomics compositional parts simple lower-level, most flexible
Picked for most React design systems quick Tailwind apps deep custom control, i18n

shadcn/ui is not a dependency — it’s Radix + Tailwind recipes you copy into your repo and own.

How do you style a headless component?

Target its parts with classes (Tailwind or CSS), and style based on the data attributes it exposes for state:

tsx
<Dialog.Content className="rounded-lg bg-white p-6 data-[state=open]:animate-in" />

Radix sets data-state, data-disabled, data-side, etc., so you style states in CSS without tracking them yourself — no className={isOpen ? ...} plumbing.

Gotchas

  • asChild requires a single, ref-forwarding child — multiple children or a child that drops ref/props breaks the merge.
  • Headless ≠ automatically accessible if you misuse it — e.g., not labeling a dialog, or breaking the part structure. You still must wire labels and follow the composition.
  • Positioning/portals — popovers/tooltips render in a portal (Dialog.Portal) to escape overflow:hidden/stacking contexts; remember to theme the portal root and handle z-index (CSS).
  • Animation on unmount — content leaving needs the library’s exit-animation hook (forceMount + presence) or it disappears before the transition.
  • Bundle: import only the primitives you use; tree-shaking keeps it lean, but pulling a whole kit you barely use isn’t free.

Further reading

Interview angle 4

  • “Why use a headless component library?” - it gives you behaviour and accessibility and leaves the styling to you. Focus trapping, roving tabindex, typeahead, aria-activedescendant, scroll locking and returning focus on close are a long tail that takes weeks to get right and that users with assistive technology notice immediately.
  • “What is asChild for?” - merging the behaviour onto an element you already render rather than adding a wrapper. It is Radix’s answer to the same problem a polymorphic as prop solves, and it avoids both the extra DOM node and the generic-typing difficulty.
  • “How do you style component states?” - the data-state attributes these libraries expose - data-state="open", data-disabled - so the styling stays in CSS instead of conditional class logic in the component. It also works with plain CSS, Tailwind or anything else.
  • “Radix or React Aria?” - Radix for a typical design system, since it ships composable components with a coherent API. React Aria is hook-level, which is more work and the right choice when you need deep control over the DOM or its internationalisation behaviour. shadcn is not a third option - it is copy-in recipes built on Radix.