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:
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:
<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
asChildrequires a single, ref-forwarding child — multiple children or a child that dropsref/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 escapeoverflow: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.
Related
Further reading
- Radix Primitives: https://www.radix-ui.com/primitives/docs/overview/introduction
- React Aria: https://react-spectrum.adobe.com/react-aria/
- Headless UI: https://headlessui.com/
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
asChildfor?” - merging the behaviour onto an element you already render rather than adding a wrapper. It is Radix’s answer to the same problem a polymorphicasprop solves, and it avoids both the extra DOM node and the generic-typing difficulty. - “How do you style component states?” - the
data-stateattributes 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.