Radix Primitives vs Base UI: A Production Guide to Next-Gen Headless Component Architectures
A deep, side-by-side evaluation of Radix Primitives and Base UI across composition APIs, styling hooks, accessibility compliance, form integration, SSR, and bundle cost. Learn which headless architecture fits your design system — and how to migrate without rewriting the world.
Photo by Nemuel Sereti on PexelsThe Headless Contract: What You Are Actually Buying
Headless component libraries are not "styling libraries with the CSS removed." They are a transfer of risk: you hand over the parts of the UI that are expensive and unforgiving to get right — focus traps, roving tabindex, collision-aware positioning, aria-activedescendant bookkeeping, scroll locking — and you keep only the parts that actually differentiate your product.
That framing matters when comparing Radix Primitives and Base UI, because both libraries pass a surface-level demo review. The differences surface under production load: RTL layouts, nested modals, virtualized menus, server-rendered routes, and server-side form validation.
The ten guarantees of a production headless primitive
Audit any candidate against this checklist. Both libraries cover the list; they differ in how they expose it.
- ARIA wiring — roles, states, and properties per the WAI-ARIA Authoring Practices Guide.
- Keyboard semantics — Escape, arrow keys, Home/End, typeahead, focus looping.
- Focus management — initial focus, focus trap, restoration to the trigger on close.
- Dismissal — outside pointer events, Escape, scroll lock where appropriate.
- Positioning — collision-aware flip/shift, RTL awareness, anchor tracking on scroll.
- State model — controlled and uncontrolled parity behind one predictable API.
- Form participation —
name/valuesubmission, validation, reset semantics. - Animation lifecycle — a way to keep a node mounted while an exit animation runs.
- Composition — behavioral and styling extension without forking internals.
- SSR/hydration safety — stable IDs and portals that do not break hydration.
The four-layer model
Every serious headless stack decomposes the same way. Knowing which layer a library owns tells you exactly where your migration cost will land.
| Layer | Responsibility | Radix Primitives | Base UI |
|---|---|---|---|
| Behavior primitives | ARIA, keyboard, focus, dismissal | @radix-ui/react-* (one package per component) | @base-ui-components/react (single package, subpath imports) |
| Composition API | Merging your element with theirs | asChild + Slot | render prop (element or function) |
| Styling contract | Hooks for CSS / CSS-in-JS | data-state, data-side, data-align | data-open, data-starting-style, data-ending-style, CSS custom properties |
| Design system | Tokens, variants, docs, governance | Your layer | Your layer |
Radix Primitives: Composition via asChild
Radix ships one npm package per component and composes internals from a small, well-documented set of shared primitives: DismissableLayer, FocusScope, Popper, RovingFocusGroup, Presence, Slot, and useControllableState. That modularity is an architectural feature — you can depend on @radix-ui/react-popover without pulling in the menu system.
Data attributes are the styling API
Radix deliberately refuses to ship a styling runtime. Instead it exposes state as data-* attributes and lets CSS do what CSS does best:
.dialog-overlay[data-state='open'] { animation: fade-in 150ms ease-out; }
.dialog-overlay[data-state='closed'] { animation: fade-out 100ms ease-in; }
.dialog-content[data-state='open'] { animation: zoom-in 150ms cubic-bezier(0.16, 1, 0.3, 1); }
.dialog-content[data-state='closed'] { animation: zoom-out 100ms ease-in; }Production-grade Radix dialog
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
type ConfirmDeleteProps = {
projectId: string;
onConfirm: () => Promise<void>;
};
export function ConfirmDeleteDialog({ projectId, onConfirm }: ConfirmDeleteProps) {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleConfirm(): Promise<void> {
setPending(true);
setError(null);
try {
await onConfirm();
setOpen(false);
} catch (err) {
// Never swallow: surface the failure inside the dialog so focus stays put.
setError(err instanceof Error ? err.message : 'Unexpected error');
} finally {
setPending(false);
}
}
return (
<Dialog.Root open={open} onOpenChange={(next) => !pending && setOpen(next)}>
<Dialog.Trigger asChild>
<button type="button" className="btn btn-danger">Delete project</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content
className="dialog-content"
aria-describedby={error ? 'delete-error' : undefined}
onEscapeKeyDown={(event) => pending && event.preventDefault()}
>
<Dialog.Title>Delete {projectId}?</Dialog.Title>
<p>This action is permanent and cannot be undone.</p>
{error && <p id="delete-error" role="alert">{error}</p>}
<Dialog.Close asChild>
<button type="button" disabled={pending}>Cancel</button>
</Dialog.Close>
<button type="button" onClick={handleConfirm} disabled={pending}>
{pending ? 'Deleting…' : 'Delete'}
</button>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Where Radix strains in 2025
asChildaccepts exactly one child element. Wrapping a button around an icon requiresSlottable, which surprises teams.- Coverage gaps: there is no Combobox, Autocomplete, NumberField, Form, or Field primitive. Teams typically reach for
cmdk, custom ARIA, or React Hook Form wiring. onOpenChange(open: boolean)carries no reason. You cannot distinguish Escape from outside-click without extra instrumentation.Selectvalues are strings only, which forces lossy serialization when your domain IDs are objects or numbers.- Portals render client-side only;
ForceMountis your escape hatch for animated or pre-rendered content.
Base UI: State-First APIs and the render Prop
Base UI — the unstyled library from the MUI team — rebuilds the primitive layer around a modernized API. In late 2024 the Radix and Base UI teams announced a collaboration to converge their architectures, with Base UI positioned as the forward-looking foundation; Radix Primitives remains maintained for existing consumers. Verify the current status against the official release notes before betting a multi-year roadmap on it.
Positioning is an explicit element
Radix hides the popper inside Content. Base UI exposes Portal → Positioner → Popup, which gives you a real DOM node to attach --transform-origin, --available-width, and --anchor-width to. This is the single biggest ergonomic win for custom-authored dropdowns and comboboxes.
import { Dialog } from '@base-ui-components/react';
import { useState } from 'react';
export function ConfirmDeleteDialog({ projectId, onConfirm }: {
projectId: string;
onConfirm: () => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
return (
<Dialog.Root
open={open}
onOpenChange={(next, eventDetails) => {
// `reason` distinguishes escape-key vs outside-press vs trigger-press.
if (pending && eventDetails.reason !== 'trigger-press') return;
setOpen(next);
}}
>
<Dialog.Trigger
render={<button type="button" className="btn btn-danger" />}
>
Delete project
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop className="dialog-overlay" />
<Dialog.Popup className="dialog-content">
<Dialog.Title>Delete {projectId}?</Dialog.Title>
<Dialog.Description>This action cannot be undone.</Dialog.Description>
<Dialog.Close render={<button type="button" disabled={pending} />}>
Cancel
</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}Animations without a Presence component
Base UI keeps the popup mounted during exit animations and exposes data-starting-style / data-ending-style. You write CSS transitions, not mount orchestration:
.dialog-content {
transition: opacity 150ms, transform 150ms;
opacity: 1;
transform: scale(1);
}
.dialog-content[data-starting-style],
.dialog-content[data-ending-style] {
opacity: 0;
transform: scale(0.96);
}
.popup {
transform-origin: var(--transform-origin); /* supplied by Positioner */
}Generically typed Select
Unlike Radix, Select.Root is generic over Value, so you can select domain objects without a serialization layer:
import { Select } from '@base-ui-components/react';
type Region = { id: number; label: string };
export function RegionPicker({ regions }: { regions: Region[] }) {
return (
<Select.Root items={regions} defaultValue={regions[0]} name="regionId">
<Select.Label>Region</Select.Label>
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Portal>
<Select.Positioner sideOffset={8} alignItemWithTrigger>
<Select.Popup>
{regions.map((region) => (
<Select.Item key={region.id} value={region}>
<Select.ItemText>{region.label}</Select.ItemText>
<Select.ItemIndicator>✓</Select.ItemIndicator>
</Select.Item>
))}
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
);
}Base UI also ships Form, Field.*, NumberField, Autocomplete, Combobox, and Toast — the gaps that most often force Radix users into third-party dependencies.
Head-to-Head Evaluation Matrix
| Dimension | Radix Primitives | Base UI | Practical impact |
|---|---|---|---|
| Composition | asChild + Slot, one child | render={<el />} or render={(props, state) => …} | Base UI can branch on state inside the child |
| Change events | onOpenChange(open) | onOpenChange(open, { reason }) | Reason-aware close guards |
| Styling hooks | data-state, data-side, data-align | data-open, data-closed, data-starting-style, CSS vars | Fewer custom keyframes in Base UI |
| Coverage | Broad, minus form/combobox primitives | Broad, includes Field/Form/Combobox/NumberField | Base UI reduces dependency sprawl |
| Value typing | Select values are strings | Select is generic over Value | Fewer adapter layers |
| Bundle | Per-component packages | One package, ESM subpaths | Both tree-shake; measure per app |
| Governance | WorkOS | MUI, converging with Radix team | Long-term consolidation likely |
Composition Ergonomics: asChild vs render
Both approaches require your component to forward refs and spread props — that contract is unavoidable. The differences are about expressiveness and failure modes.
Radix's Slot merges props and event handlers (child handler first, then the parent's, unless preventDefault() is called) and merges refs. Its failure mode is silent: forget forwardRef in your IconButton and focus lands on nothing when the tooltip closes.
Base UI's render accepts a function receiving (props, state), which enables patterns Radix cannot express without internal knowledge:
<Toggle render={(props, state) => (
<button {...props} aria-pressed={state.pressed} />
)}>
Bold
</Toggle>Pitfall: inline
renderfunctions and function-valuedclassName/stylecreate new identities on every render and defeat memoization in hot lists. Hoist them to module scope or wrap inuseCallback.
Do not build a universal adapter
A tempting architectural move is to abstract both libraries behind one API. Resist it by default: the composition model is the value proposition. If you genuinely need to hedge, isolate the swap inside leaf components (e.g. ui/dialog.tsx) rather than building a generic primitive shim — you will lose render-function state access and reason-carrying events the moment you flatten the API.
Accessibility Compliance Is a Test Suite, Not a Feature
"Accessible by default" is marketing until it is asserted in CI. Every headless primitive should be guarded by three layers of tests: unit ARIA assertions, keyboard-driven interaction tests, and axe scans in a real browser.
// dialog.a11y.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
import { expect, test } from 'vitest';
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog';
const noop = async (): Promise<void> => {};
test('exposes a labelled modal dialog with no axe violations', async () => {
const { baseElement } = render(
<ConfirmDeleteDialog projectId="acme" onConfirm={noop} />,
);
await userEvent.click(screen.getByRole('button', { name: /delete project/i }));
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAccessibleName(/delete acme/i);
const results = await axe.run(baseElement, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
test('traps focus and restores it to the trigger on Escape', async () => {
render(<ConfirmDeleteDialog projectId="acme" onConfirm={noop} />);
const trigger = screen.getByRole('button', { name: /delete project/i });
trigger.focus();
await userEvent.keyboard('{Enter}');
await screen.findByRole('dialog');
// Tab cycling must stay inside the dialog.
await userEvent.tab();
await userEvent.tab();
expect(document.activeElement).not.toBe(trigger);
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).toBeNull();
expect(document.activeElement).toBe(trigger);
});Important caveat: axe-in-jsdom cannot evaluate color contrast, element overlap, or pointer-target size. Run @axe-core/playwright against a real browser in your e2e pipeline for those rules, and keep the jsdom suite for the fast feedback loop.
Bundle Size: Measure Inside Your App
Published per-package numbers mislead because shared internals (DismissableLayer, floating-ui, focus scope) deduplicate differently per bundle. Measure the actual delta with esbuild and gzip:
// scripts/measure.mjs
import { build } from 'esbuild';
import { gzipSync } from 'node:zlib';
const targets = {
'radix/dialog': "export { Root as Dialog } from '@radix-ui/react-dialog'",
'base/dialog': "export { Dialog } from '@base-ui-components/react'",
};
for (const [name, contents] of Object.entries(targets)) {
const result = await build({
stdin: { contents, resolveDir: process.cwd(), loader: 'tsx' },
bundle: true,
minify: true,
format: 'esm',
write: false,
external: ['react', 'react-dom'],
});
const bytes = gzipSync(Buffer.from(result.outputFiles[0].text)).byteLength;
console.log(`${name}: ${(bytes / 1024).toFixed(1)} kB gzipped`);
}Run it on your entry points before and after adoption, not on a vendor's comparison chart.
SSR, Hydration, and Portals
- Both libraries depend on stable IDs. Radix uses React's
useId; Base UI does the same internally — never hand-rollMath.random()IDs foraria-labelledby. - Portals mount into
document.bodyafter hydration. Expose acontainerprop so dialogs work inside shadow DOM, iframes, and micro-frontends. - For pre-rendered or SEO-critical content, use Radix
ForceMountor Base UIkeepMountedrather than removing the component conditionally. - Do not render an animated popup with
display: none— both libraries rely on measured layout for positioning. Animateopacity/transformonly.
Migration Strategy and the Convergence Question
The honest assessment: both are production-grade. Choose on coverage and composition ergonomics, not on hype.
Stay on Radix if you already have a large shadcn-style surface, your Select values are strings, and you have no combobox or number-field requirements.
Adopt Base UI if you need Field/Form validation primitives, a generic Select, explicit Positioner control, or reason-carrying events — the areas where Radix requires bespoke code today.
If you do migrate, do it leaf-by-leaf and keep the two families from sharing a single dialog tree: focus scopes from different libraries do not negotiate nesting. Wrap each family in your own ui/ module so call sites never import vendor packages directly.
Operational checklist before committing
- Run axe in Playwright against every overlay component in light and dark themes.
- Test keyboard-only traversal of Menu, Tabs, and Combobox under RTL.
- Verify nested modal → popover → tooltip stacking and Escape unwinding order.
- Confirm focus restoration to the trigger after async close.
- Assert form submission payloads include the primitive's hidden inputs.
- Measure gzip delta on the top three routes, not on a blank page.
- Pin peer ranges for React 18 and 19 and test both in CI.
Pitfalls to avoid
- Disabled vs. `aria-disabled`: some components keep disabled items focusable so screen readers can announce them; verify per-component rather than assuming.
- Animation-gated unmounts: closing a dialog by unmounting the parent destroys the exit transition and the focus-restore target.
- Styling by internal class names: neither library guarantees them; use
data-*attributes only. - `className` functions in React Server Components: they are client-side stateful; mark the file
'use client'.
Verdict
Radix Primitives is a mature, modular, CSS-first foundation whose asChild model is now widely understood across the ecosystem. Base UI is the state-first successor: explicit positioners, reason-carrying events, generically typed selects, and first-class form primitives. For greenfield design systems in 2025, Base UI has the stronger long-term API surface; for existing Radix estates, the migration should be driven by concrete coverage gaps, not by convergence rumors.
Sources & Technical References
- Radix Primitives — Introduction: https://www.radix-ui.com/primitives/docs/overview/introduction
- Radix Primitives — Composition guide (
asChild,Slot): https://www.radix-ui.com/primitives/docs/guides/composition - Radix Primitives — Styling and data attributes: https://www.radix-ui.com/primitives/docs/guides/styling
- Radix Primitives — Accessibility: https://www.radix-ui.com/primitives/docs/overview/accessibility
- Radix Primitives — GitHub repository and release notes: https://github.com/radix-ui/primitives
- Base UI — Quick start and component reference: https://base-ui.com/react/overview/quick-start
- Base UI — Composition handbook (
renderprop): https://base-ui.com/react/handbook/composition - Base UI — Styling handbook (data attributes, CSS variables): https://base-ui.com/react/handbook/styling
- Base UI — Accessibility overview: https://base-ui.com/react/overview/accessibility
- Base UI — GitHub repository: https://github.com/mui/base-ui
- W3C — WAI-ARIA Authoring Practices Guide: https://www.w3.org/WAI/ARIA/apg/
- W3C — ARIA Authoring Practices: Dialog (Modal) Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
- W3C — Accessible Rich Internet Applications (WAI-ARIA) 1.2 Specification: https://www.w3.org/TR/wai-aria-1.2/
- W3C — WCAG 2.2 Quick Reference: https://www.w3.org/WAI/WCAG22/quickref/
- MDN Web Docs — ARIA reference: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA
- Floating UI — Component library integrations used by both ecosystems: https://floating-ui.com/docs/misc#component-libraries
- React Documentation —
useIdand hydration-safe identifiers: https://react.dev/reference/react/useId - Deque Systems — axe-core rule descriptions: https://github.com/dequelabs/axe-core
- Testing Library —
user-eventkeyboard interaction model: https://testing-library.com/docs/user-event/intro - Playwright — Accessibility testing with axe: https://playwright.dev/docs/accessibility-testing
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

Mastering Incremental Static Regeneration and Stale-While-Revalidate in Next.js
ISR is not a magic freshness switch — it is a two-layer cache contract between your framework and the CDN. Learn how to wire tag-based revalidation, align stale-while-revalidate windows, and avoid the production pitfalls that silently break caching.
Biggest Hack in the World: Binance Suffers Unprecedented $6.3 Billion Multi-Chain Cyberattack
A comprehensive technical autopsy of the $6.3B Binance hot-wallet breach attributed to TraderTraitor (Lazarus Group). How automated sweeping, compromised DevOps credentials, and cross-chain execution sparked the largest heist in financial history.

Mobile Push at 100M Devices: Benchmarking FCM, APNs, and Custom WebSockets
At 100M devices, push fails in the tail, not the median. We break down real delivery-latency percentiles for APNs, FCM, and owned WebSockets, plus a reproducible cost model and the reliability patterns behind 99.99%.