Primitives belong in a versioned package. Most teams should not hand-roll every focus trap. Pick the accessible primitive layer you buy, and spell out what you still owe after install.
I compare libraries on three axes: a11y guarantees, styling escape hatches, and bundle / ownership cost. Logo loyalty is not an axis.
The cast (mid-2025)
| Library | Model | You own styling? |
|---|---|---|
| Radix UI Primitives | Unstyled behavior primitives | Yes: compose classes/tokens |
| React Aria / React Spectrum | Behavior hooks + optional Spectrum chrome | Hooks: yes; Spectrum: opinionated |
| Headless UI | Unstyled components (Tailwind-adjacent docs) | Yes |
| Base UI (MUI Base lineage) | Unstyled primitives | Yes |
| shadcn/ui-style kits | Copy-paste composed components on primitives | You own the source in-repo |
I am not crowning a winner. I am naming what you are actually purchasing.
Axis 1: Accessibility guarantees
Ask: what is tested and documented, keyboard, focus, ARIA roles, and screen reader expectations?
Radix and React Aria both treat WAI-ARIA patterns as the product. Dialogs move focus, restore focus, and handle Escape. Tabs implement roving tabindex. That baseline is what you are buying.
Headless UI covers common patterns well; depth varies by component, read the docs for the control you need, don't assume parity with Radix's full set.
Copy-paste kits (shadcn-style) inherit whatever primitive they wrap. Your
guarantee is only as good as (a) the upstream primitive and (b) whether your
fork still wires aria-* after restyles.
tsx// illustrative: Radix Dialog composition with tokens import * as Dialog from '@radix-ui/react-dialog'; export function ConfirmDialog({ open, onOpenChange, title, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: string; children: React.ReactNode; }) { return ( <Dialog.Root open={open} onOpenChange={onOpenChange} > <Dialog.Portal> <Dialog.Overlay className="acme-overlay" /> <Dialog.Content className="acme-dialog" aria-describedby={undefined} > <Dialog.Title>{title}</Dialog.Title> {children} <Dialog.Close className="acme-btn">Close</Dialog.Close> </Dialog.Content> </Dialog.Portal> </Dialog.Root> ); }
If you replace Dialog.Title with a styled <div>, you silently deleted the
accessible name. Libraries cannot save you from that.
Axis 2: Styling escape hatches
Unstyled primitives win when design tokens (May) and themes (June) are real. You attach classes or data attributes; the library stays out of your visual system.
Spectrum (and other full design languages) win when you want a coherent Adobe-like UI fast and will accept their visual gravity.
Escape-hatch failure modes:
- Fighting a styled kit with
!importantand wrapper divs - Re-implementing padding in three layers because the kit ships opinions and your tokens disagree
- Choosing "fully custom" then shipping inaccessible custom selects anyway
tsx// React Aria hook-style, behavior without chrome (illustrative) import { useToggleButton } from 'react-aria'; import { useToggleState } from 'react-stately'; import { useRef } from 'react'; export function MuteToggle() { const state = useToggleState(); const ref = useRef<HTMLButtonElement>(null); const { buttonProps, isSelected } = useToggleButton({}, state, ref); return ( <button {...buttonProps} ref={ref} data-selected={isSelected} > {isSelected ? 'Muted' : 'Sound on'} </button> ); }
Hooks maximize styling freedom. They also maximize you must compose correctly: there is no Storybook default to hide mistakes.
Axis 3: Bundle and ownership cost
| Approach | Bundle shape | Ownership |
|---|---|---|
| Per-primitive packages (Radix-style) | Pay for what you import | Upstream a11y + your styles |
| Monolithic headless | Tree-shake quality varies | Same |
| Copy-paste kit | Code lives in your repo | You merge upstream by hand |
| Full design system | Larger, coherent | Theme tokens vs their theme |
Copy-paste is not free. You own diffs, a11y regressions, and dependency bumps for the underlying primitive. That is often the right trade for a single product with a strong design hand, and the wrong trade if nobody reviews focus behavior when "just changing the border radius."
Decision rule: if you need three dialogs and a select, start with primitives + tokens. If you need a full enterprise chrome tomorrow and design bandwidth is near zero, a complete system may be cheaper than a fake custom kit.
What I still build myself
Even with a strong primitive layer, I still own:
- Domain components (primitives-to-domain post)
- Layout shells and navigational patterns (responsive layout post)
- Product empty states and error copy
- Any control the library does not cover, with an a11y checklist before merge
Buying primitives is not outsourcing responsibility. It is refusing to
re-implement aria-modal incorrectly for the fifth time.
Continuity
Primitives versus domain components set the package split. Accessible libraries are the behavior substrate under those primitives. Responsive layout, sidebar and mobile patterns follow, where CSS often beats extra component chrome.
Takeaway
Pick accessible libraries on guarantees, styling freedom, and who owns the diffs, not on which logo your last team used.