shadcn/ui with Base UI: Setup and Migrating from Radix
shadcn/ui now builds on Base UI by default. How to set it up in Next.js, what replaces asChild, what changed from Radix, and the errors you hit when migrating.
For most of its life, shadcn/ui meant Radix under the hood. That is no longer the default. Run npx shadcn@latest init with the current CLI (4.x) and the recommended choice builds every component on Base UI instead of Radix. My portfolio and my design system both run on it, and this post covers the setup plus everything that changed when I moved components over.
Base UI is an unstyled, accessible component library from people who worked on Radix, Material UI and Floating UI. The package is @base-ui/react. If you see @base-ui-components/react in older posts or issues, that is the same library under its previous name.
Setting up shadcn/ui with Base UI
In a new or existing Next.js project:
npx shadcn@latest initThe CLI asks you to select a component library, and "Base" is the option marked recommended. Pick it, or skip the prompt with a flag:
npx shadcn@latest init --base base --preset nova--base radix still exists if you want the old behaviour. --base picks the library and --preset picks the visual style: nova, vega, maia, lyra, mira, luma, sera or rhea. Nova, the default, pairs Lucide icons with Geist. The two combine into the style field of components.json, so Base UI with Nova is saved as base-nova:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide"
}Adding components works exactly as before:
npx shadcn@latest add button dropdown-menu tooltip dialogThe generated files import from @base-ui/react/* instead of radix-ui, and the CLI installs @base-ui/react for you. The class names, the tokens in globals.css and the cn() helper are unchanged, so your theme and your Tailwind setup carry over as they are.
asChild is gone, use render
This is the change that touches the most call sites. Radix lets a component hand its behaviour to its child with asChild. Base UI does the same job with a render prop that takes the element to render.
The most common case is a button that is really a link:
// Radix
<Button asChild>
<Link href="/">Back home</Link>
</Button>
// Base UI
<Button render={<Link href="/" />} nativeButton={false}>
Back home
</Button>The children move out of the rendered element and into the component itself. nativeButton={false} tells Base UI the rendered element is not a real <button>, so it stops applying button-only defaults like type="button" and adds the keyboard and ARIA handling a non-button needs. Leave it off when render still produces a button.
Triggers work the same way. Here is a dropdown menu opened by a shadcn Button:
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
Account
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem render={<a href="/settings" />}>
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>;
Stacking behaviours, like a button that opens a dialog and also shows a tooltip, used to mean nesting asChild wrappers. With render you nest the triggers instead:
<TooltipTrigger
render={
<DialogTrigger render={<Button variant="destructive" size="icon" />} />
}
>
<TrashIcon />
</TooltipTrigger>;Whatever you pass to render has to forward its ref and spread the props it receives onto the DOM node. shadcn components already do both, so this only matters for your own wrappers.
render also accepts a function, which is handy when the output depends on the component's state:
<Switch.Thumb
render={(props, state) => (
<span {...props}>{state.checked ? <MoonIcon /> : <SunIcon />}</span>
)}
/>;Data attributes changed
Radix exposes state as data-state="open" or data-state="closed". Base UI uses separate presence attributes, so any custom styles keyed to data-state stop matching:
| Radix | Base UI |
|---|---|
data-[state=open]: | data-open: |
data-[state=closed]: | data-closed: |
data-[state=checked]: | data-checked: |
data-[disabled]: | data-disabled: |
trigger data-[state=open]: | trigger data-popup-open: |
Tailwind v4 matches a bare data-open: variant against the attribute being present, which is why the shadcn Base UI components read so much cleaner than their Radix versions:
// Radix version
"data-[state=open]:animate-in data-[state=closed]:animate-out";
// Base UI version
"data-open:animate-in data-closed:animate-out";Base UI also adds data-starting-style and data-ending-style for the frames where a popup is animating in or out. They let you write enter and exit animations as plain CSS transitions, without keyframes:
.popover {
transition:
opacity 150ms,
scale 150ms;
}
.popover[data-starting-style],
.popover[data-ending-style] {
opacity: 0;
scale: 0.96;
}CSS variables changed too
Radix prefixes its positioning variables per component. Base UI uses one short set everywhere:
| Radix | Base UI |
|---|---|
--radix-dropdown-menu-content-transform-origin | --transform-origin |
--radix-popover-trigger-width | --anchor-width |
--radix-select-content-available-height | --available-height |
In Tailwind that is origin-(--transform-origin), w-(--anchor-width) and max-h-(--available-height), which is what the generated components use.
Positioning props live on the Positioner
In Radix, side, align and sideOffset go on the Content part. Base UI splits floating components into a Positioner that handles placement and a Popup that holds the content. The shadcn wrappers hide that split: DropdownMenuContent, PopoverContent and TooltipContent still accept side, align, sideOffset and alignOffset and pass them to the Positioner for you. You only notice the split when you drop down to the raw @base-ui/react parts.
Base UI also lets a popup anchor to something other than its trigger, through the Positioner's anchor prop. The generated PopoverContent does not forward it, so add it to the props it picks and pass it through:
function PopoverContent({
align = "center",
side = "bottom",
sideOffset = 4,
anchor,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "anchor"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
side={side}
sideOffset={sideOffset}
anchor={anchor}
>
<PopoverPrimitive.Popup {...props} />
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}I use this on my portfolio so the table of contents popover opens centred over the whole dock instead of over the small button that opened it:
<PopoverContent side="top" sideOffset={14} anchor={dockRef}>
<Toc items={tocItems} />
</PopoverContent>;Smaller prop renames
A few props changed name or shape. The ones I ran into:
- Tooltip delay: Radix's
delayDurationonTooltipProviderisdelayin Base UI, set on the provider for a group or on a single tooltip. The default is 600ms; shadcn's provider sets 0. onOpenChangestill receives the new open state first, but Base UI passes a second argument with event details, including areasonsuch as"outside-press"or"escape-key". That is useful when a dialog should close on Escape but not on an outside click.- Dialog
modalacceptstrue,false, or"trap-focus", which traps focus without locking page scroll.
Moving an existing Radix project over
There is no codemod, but the migration is mostly mechanical. On a branch:
- Change
styleincomponents.jsonfrom your current Radix style to its Base UI equivalent, for exampleradix-novatobase-nova. - Re-add each component you use so the files are regenerated on Base UI.
--dry-runshows what will change first.
npx shadcn@latest add button dropdown-menu dialog --dry-run
npx shadcn@latest add button dropdown-menu dialog --overwrite- If you edited the generated components, re-apply those changes by hand.
--diffshows how your copy differs from the registry version. - Search the codebase for
asChild,data-[state=and--radix-and update each hit using the tables above. - Remove
radix-ui(or the individual@radix-ui/*packages) frompackage.jsononce nothing imports them.
Nothing forces you to do it all at once. Both libraries can live in the same project while you migrate, one component at a time.
What tripped me up
These are the problems I actually ran into moving my portfolio, my blog and my design system to Base UI.
The nativeButton error on every link button
I hit this on every button that renders a Next.js Link: the "Back home" button on my 404 page, the back buttons on case study pages, and the page links in my site's dock. The fix per call site is nativeButton={false}, but in a wrapper that sometimes renders a link and sometimes a real button, you can derive it from the render prop instead:
function DockItem({
label,
...props
}: React.ComponentProps<typeof Button> & { label: string }) {
return (
<Button
variant="ghost"
size="icon"
// links (render={<Link />}) aren't native buttons
nativeButton={!props.render}
aria-label={label}
{...props}
/>
);
}Now <DockItem render={<Link href="/work" />} /> and <DockItem onClick={...} /> both work without the caller thinking about it.
My own component as a trigger
The dock's table of contents button is a DockItem that also opens a popover, so it goes in the trigger's render prop:
<PopoverTrigger render={<DockItem label="On this page" active={tocOpen} />}>
<MenuIcon />
</PopoverTrigger>;This works because DockItem spreads everything it receives onto the underlying Button. Base UI passes the trigger's click handler, ref and ARIA attributes through render, so a wrapper that picks out only the props it knows about would leave the popover with no way to open.
Copies of Button drifting apart
shadcn copies components into your project, which is the point, but I had customised button.tsx in both my portfolio and my blog, and keeping the two in step meant making every change twice. When I moved to Base UI I put the shared components into one package, @thilina-dev/design-system, and deleted the local copies. If you run more than one app on the same components, a small package means each Base UI change happens once.
Common errors
Property 'asChild' does not exist on type
The component was regenerated on Base UI but a call site still passes asChild. Move the child element into render and the child's children into the component, as shown above.
Base UI: A component that acts as a button expected a native button
The full message in the console reads:
Base UI: A component that acts as a button expected a native <button> because the `nativeButton` prop is true. Rendering a non-<button> removes native button semantics, which can impact forms and accessibility. Use a real <button> in the `render` prop, or set `nativeButton` to `false`.You passed render={<Link />} or render={<a />} to a Button without nativeButton={false}. Add the prop and the error goes away.
Custom open or closed styles stopped applying
The styles target data-state="open". Switch to data-open and data-closed, or data-popup-open when you are styling a trigger.
Module not found: Can't resolve '@base-ui-components/react'
You copied an import from an older example. Use @base-ui/react, and import each part from its own entry point, for example @base-ui/react/menu.
A trigger does nothing when clicked
The element passed to render is your own component, and it drops the props it receives or does not forward its ref. Base UI attaches its click handlers, ARIA attributes and ref through those props, so spread every prop onto the underlying DOM element.
Conclusion
For a new project, npx shadcn@latest init and a Base UI preset is all the setup there is. For an existing one, the real work is replacing asChild with render and updating selectors that rely on Radix's data-state and --radix-* variables. Everything else, including the theme, the tokens and how you add components, stays the same.
Have a wonderful day.