Headless UI and Radix
TL;DR
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).
In depth
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 / edge cases
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.
What a senior is expected to say 4
- “Headless libraries give behavior + a11y, I bring styles. I don’t hand-roll a combobox/dialog — Radix/React Aria solved the focus/keyboard/ARIA long tail.”
- “Radix’s
asChild/Slotmerges behavior onto my element instead of adding wrappers — an alternative to a polymorphicas.” - “They support controlled and uncontrolled, and expose
data-stateso I style states in CSS.” - “React Aria is hook-level and best for deep custom control + i18n; Radix is the common design-system choice; shadcn is copy-in recipes over Radix.”
Cross-references
- The accessibility these libraries implement: Accessible Components (APG Patterns)
- Polymorphic
as(the other composition approach): Polymorphic Components (the as prop) - Controlled vs uncontrolled: Controlled vs Uncontrolled Components
- Styling them with Tailwind: Tailwind CSS Utility Classes
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/