All posts
15 min read

Base UI Is the New shadcn/ui Default: A Complete Radix Migration Guide

shadcn/ui now ships on Base UI instead of Radix. This is the precise migration map: the asChild to render shift, the Portal-Positioner-Popup model, every renamed part, the call-site props that change, the behavior deltas to watch, and how Beste UI serves both libraries from one source.

base-uiradixshadcnreactmigration

On July 3, 2026, shadcn/ui changed the primitive library underneath every component it ships. New installs now come wired to Base UI, not Radix. If you run npx shadcn@latest info, the base field is the source of truth for which one a given project is on.

This is a bigger change than a version bump, and a smaller one than a rewrite. The component names you import stay the same. The wiring inside them, and a handful of props at the places you use them, do not. This post is the complete map: the four shifts that explain every difference, the full rename table, the call-site props that break, the behavior changes that compile fine but act differently, and a migration path that keeps the app building at every step.

First, the announcement, in the author's own words:

Why the default moved

Radix was the right foundation in 2023, and it still works today. What changed is not that Radix got worse. A second option earned the default through use rather than hype, and the shadcn team took the patient route to get there.

When Base UI reached beta, and again when it reached 1.0, the obvious question was whether it would replace Radix. Both times the answer was "not yet." Rather than force a swap, shadcn did the incremental thing: it added Base UI support to shadcn/create, recreated every component on it, rewrote the documentation for both libraries, and let people choose Radix or Base at setup. Then it watched what people actually shipped.

By the middle of 2026, every signal it had been waiting on had arrived:

So this was less a decision handed down than one the community had already made. Making it official flips three defaults: a fresh shadcn init now scaffolds on Base UI, shadcn/create shows Base UI as the default option, and the docs lead with Base UI while the Radix version stays one click away. Because the registry is where most teams get their components, the anatomy you copy into a new project is now Base UI by default.

None of that puts your current app at risk, and this is the part worth reading twice. Radix is not being deprecated. It stays supported, and every update and new component ships for both libraries, the only exception being a primitive that exists solely in Base UI. You are not required to migrate at all. Radix is mature, tested, and still runs in production for the shadcn team today, so if your app works, keep it. The rest of this post is for the moment you decide you want what Base UI adds. Almost every difference between the two libraries reduces to four repeating shifts, so that is where we start.

The four shifts that explain everything

Learn these four and most of the diff reads itself.

1. Composition: asChild becomes render

Radix merged props onto a child through a boolean. Base UI takes the element directly through a render prop.

diff
- <DialogTrigger asChild>
-   <Button>Open</Button>
- </DialogTrigger>
+ <DialogTrigger render={<Button />}>Open</DialogTrigger>

The one addition to remember: when render swaps in an element that is not a native <button>, tell Base UI so with nativeButton={false}.

tsx
<Button render={<a href="/docs" />} nativeButton={false}>
  Read the docs
</Button>

This applies to every trigger and close part: DialogTrigger, SheetTrigger, AlertDialogTrigger, DropdownMenuTrigger, PopoverTrigger, TooltipTrigger, CollapsibleTrigger, DialogClose, NavigationMenuLink, BreadcrumbLink, and more. For hand-rolled polymorphic components that used the manual Slot idiom (const Comp = asChild ? Slot.Root : "a"), the replacement is useRender plus mergeProps from @base-ui/react. One caveat there: object literals that carry data-* keys must be cast (as React.ComponentProps<"a">) before they go into mergeProps, or the type checker rejects every one. The plain button.tsx wrapper is simpler than that: it migrates straight to the real @base-ui/react/button primitive, which accepts render natively.

2. Positioning: Portal > Content becomes Portal > Positioner > Popup

Every floating surface (popover, tooltip, menu, select, hover card) splits the old single Content into three roles. Positioning props move to a new Positioner; the styled box becomes Popup.

diff
  <PopoverPortal>
-   <PopoverContent side="bottom" sideOffset={8} align="start">
-     ...
-   </PopoverContent>
+   <PopoverPositioner side="bottom" sideOffset={8} align="start">
+     <PopoverPopup>...</PopoverPopup>
+   </PopoverPositioner>
  </PopoverPortal>

For dialogs and sheets the mirror change is Overlay to Backdrop, and centered modals use Popup with no Positioner at all. There is one discipline this model demands, and no type error will catch it for you: when a wrapper exposes positioning props via Pick<...Positioner.Props, "align" | "side" | ...>, you must destructure each one and forward it to <Positioner> yourself. Miss it, and the props fall through ...props onto the Popup, and positioning silently breaks.

Declare, destructure, forward

The Positioner rule is three steps, every time, for every overlay wrapper: declare the positioning props in the type, destructure them out of props, and pass them explicitly to Positioner. If you only do the first, they land on the wrong DOM node and the popup stops respecting side and align with no compiler complaint.

3. State hooks: value attributes become presence attributes

Radix encoded state in the value of a data-state attribute. Base UI uses presence attributes instead, which changes both your CSS selectors and your animation strategy.

RadixBase UI
data-[state=open]:data-open:
data-[state=closed]:data-closed:
data-[state=checked]:data-checked:
data-[state=active]: (tabs)data-active:
data-[state=on]: (toggle)data-pressed:
group-data-[state=open]group-data-open

Animations move from keyframe utilities to transitions. The Radix idiom of data-[state=open]:animate-in and data-[state=closed]:animate-out becomes data-starting-style: and data-ending-style: on a real CSS transition. Do not translate the animate-in utilities one for one; restate the intent as a transition between a starting and ending style. CSS variables rename on the same principle: --radix-popover-content-transform-origin becomes --transform-origin, --radix-popover-trigger-width becomes --anchor-width, and the accordion and collapsible height vars become --accordion-panel-height and --collapsible-panel-height.

One subtlety that bites: when a part changes its rendered element (Checkbox, Switch, and Radio roots render a <span> in Base UI rather than a <button>), the Tailwind disabled: and :disabled variants become dead code, because a <span> has no disabled pseudo-class. Replace them with data-disabled: equivalents.

4. Callbacks: one signature, plus a reason

Radix shipped a callback per dismissal interaction. Base UI consolidates them. onOpenChange gains a second eventDetails argument, and the per-interaction handlers collapse into a reason you branch on.

diff
- <DialogContent
-   onEscapeKeyDown={(e) => e.preventDefault()}
-   onPointerDownOutside={(e) => e.preventDefault()}
- >
+ <Dialog
+   onOpenChange={(open, details) => {
+     if (details.reason === "escape-key" || details.reason === "outside-press") {
+       details.cancel()
+     }
+   }}
+ >

eventDetails.cancel() is the new event.preventDefault(). The reasons are a small union per component ('escape-key', 'outside-press', 'focus-out', and friends). Passing an old single-argument handler stays type safe, so only the handlers that actually read the event need attention.

The rename map

Beyond the four shifts, a set of parts simply have new names. This is the reference you keep open while migrating.

Radix partBase UI part
OverlayBackdrop
Content (overlay components)Popup (inside Positioner)
Content (accordion, collapsible, tabs)Panel
tabs TriggerTab
menu LabelGroupLabel
menu ItemIndicatorCheckboxItemIndicator or RadioItemIndicator
Sub and SubTriggerSubmenuRoot and SubmenuTrigger
slider RangeIndicator (plus a new Control)
select ViewportList
select ScrollUpButton and ScrollDownButtonScrollUpArrow and ScrollDownArrow
scroll-area Scrollbar and ThumbScrollbar and Thumb (renamed exports)
nav-menu IndicatorIcon
hover-card HoverCard*PreviewCard* (public wrapper names stay HoverCard*)
radio-group Item and IndicatorRadio.Root and Radio.Indicator
DropdownMenu (the whole primitive)Menu
Label primitivenative <label>

A few primitives have no Base UI counterpart, and the migration is to plain platform features rather than a new part. AspectRatio becomes a div with the CSS aspect-ratio property. Label becomes a native <label> (or Field.Label inside a form). VisuallyHidden becomes the sr-only class. Direction is provided by DirectionProvider with a direction prop instead of dir.

The call-site props that change

The rename map covers component internals. This section is the part that reaches into your application code, because these props change where you use the components. Sweep for them across everything outside components/ui.

ComponentWhat changes at the call site
Accordiontype="single" and collapsible are dropped; value and defaultValue are always arrays; multiple-open is a multiple boolean.
TabsactivationMode is dropped; Base UI defaults to manual activation. Opt back into automatic with <TabsList activateOnFocus>.
Selectposition becomes alignItemWithTrigger (a boolean); onValueChange widens to (value | null, details); a placeholder is a { value: null } entry in an items array.
ToggleGrouptype becomes a multiple boolean; value is always an array.
Slidera single thumb accepts a plain number (no array wrapper); onValueCommit becomes onValueCommitted; inverted is removed.
Checkboxchecked="indeterminate" splits into a separate indeterminate boolean plus a boolean checked.
TooltipdelayDuration becomes delay; skipDelayDuration becomes timeout; disableHoverableContent has no equivalent.
Dialog and AlertDialogonOpenAutoFocus becomes initialFocus, onCloseAutoFocus becomes finalFocus, both element based rather than event based.
AvatarImage.delayMs becomes delay.
Separatordecorative is dropped (the separator is always semantic).
NavigationMenudelayDuration becomes delay (default 200 becomes 50); viewport is gone.
Popover and HoverCardopenDelay and closeDelay move from Root onto the Trigger.

The single most common cause of a broken build after the wrappers compile is the controlled Select. Radix gave you a string; Base UI gives you Value | null.

diff
- const [value, setValue] = useState("")
+ const [value, setValue] = useState<string | null>(null)

  <Select value={value} onValueChange={setValue}>

The array-shape family

Accordion, ToggleGroup, and single-mode Tabs share one mental model in Base UI: the value is always an array, even for single selection. value="a" becomes value={["a"]}, and a controlled single value round-trips as value={[v]} with onValueChange={(next) => setV(next[0] ?? "")}. Get this one habit and three components stop surprising you.

A worked example: the Select

Select changes the most, so it is the best teacher. In Radix, one Content did positioning, collision handling, and the panel. In Base UI, those split, the viewport becomes a List, and the root can take an items prop so the trigger can render a label instead of a raw value.

diff
- <Select value={fruit} onValueChange={setFruit}>
-   <SelectTrigger>
-     <SelectValue placeholder="Select a fruit" />
-   </SelectTrigger>
-   <SelectContent position="popper">
-     <SelectItem value="apple">Apple</SelectItem>
-     <SelectItem value="banana">Banana</SelectItem>
-   </SelectContent>
- </Select>
+ <Select items={items} value={fruit} onValueChange={setFruit}>
+   <SelectTrigger>
+     <SelectValue />
+   </SelectTrigger>
+   <SelectContent alignItemWithTrigger={false}>
+     {items.map((item) => (
+       <SelectItem key={item.value} value={item.value}>
+         {item.label}
+       </SelectItem>
+     ))}
+   </SelectContent>
+ </Select>

The placeholder is now the { label: "Select a fruit", value: null } entry at the top of items, and fruit is typed string | null. In return you get things Radix never offered: multiple, object values via itemToStringValue, and a render-function SelectValue for custom trigger content.

A worked example: the dropdown menu

Menus carry the most renamed parts, but they follow the four shifts exactly. Label becomes GroupLabel, ItemIndicator splits into CheckboxItemIndicator and RadioItemIndicator, Sub becomes SubmenuRoot, and Content expands into Portal > Positioner > Popup. The item selection model also changes: Radix used onSelect with event.preventDefault() to keep the menu open, while Base UI uses onClick plus a closeOnClick boolean. Typeahead text moves from textValue to label.

Checkbox and radio items no longer close on click

This is the behavior delta that surprises people. In Base UI, closeOnClick defaults to false on CheckboxItem and RadioItem, so toggling one keeps the menu open. Radix closed it by default. If you relied on the close, set closeOnClick explicitly. It compiles either way, so nothing warns you.

Behavior deltas that compile but act differently

These are the changes that pass the type checker and pass the build, then feel wrong in the browser. Flag them, verify them by hand, and decide each one on purpose.

Migrating without a big-bang rewrite

The safe path is the strangler fig, one component at a time, with both libraries installed side by side. @base-ui/react and the Radix packages coexist without conflict, so you remove Radix only after the last component moves.

  1. Baseline first. Run your typecheck and build before you touch dependencies, so pre-existing failures are never blamed on the migration.
  2. Install @base-ui/react alongside Radix. Work on a branch, one commit per component.
  3. Migrate leaf and shared wrappers first (button and label before the components that import them). If a component still imports another wrapper that is on Radix, migrate that one first, bottom up.
  4. Write the migrated version to component-base.tsx, leaving the original untouched. Repoint consumers one at a time, typechecking after each. When nothing imports the original, delete it, rename -base to the real name, and commit.
  5. Sweep application code for the call-site props above. The break surface there is larger than asChild alone.

For a shadcn project with a known style, the CLI does the heavy lifting: the base variant of each component is served at https://ui.shadcn.com/r/styles/base-<style>/<component>.json, so you can fetch the exact base anatomy with your project's icon and font resolution rather than reconstructing it by hand. One rule holds no matter how clean a merge looks: grep every migrated file for radix-ui, @radix-ui, and any leftover placeholder before you call it done. A clean three-way merge is not proof of a clean file.

The official migration skill

You do not have to drive these transforms by hand. shadcn shipped tooling for this exact workflow, a skill your coding agent runs component by component. In their words:

When you're ready to migrate, we built a skill for you.

npx skills add shadcn/ui

Then ask your coding agent: "migrate alert-dialog to base ui"

It's progressive: migrate one component at a time while your project stays green. Stop halfway, ship, come back next week. It picks up where you left off.

- shadcn

The shadcn/ui skill migrating a component from Radix to Base UI

That is the same strangler-fig model described above, automated: the project stays green at every step, and the work is resumable, so a large migration becomes a series of small, shippable commits instead of one risky rewrite.

What migration never touches

Several shadcn components are not Radix at all, and they stay exactly as they are: cmdk (command), vaul (drawer), sonner, input-otp, react-day-picker (calendar), and recharts (chart). If a migration tool offers to rewrite them, it is wrong. Leave them, and note them as intentionally untouched.

How Beste UI serves both, from one source

We took a deliberate stance so you never have to choose a camp at install time. Our canonical component source stays on Radix, and the Base UI variant is generated at build time by a codemod that applies exactly the transforms in this post: asChild to render with nativeButton, the array-shape value rewrites, the data-attribute and CSS-variable renames, the callback reshaping. The Base UI output is served from a parallel set of URLs under /r-base/, and the install control defaults to Base UI to match the new shadcn default.

The practical result is that every block, piece, and component here installs in whichever flavor your project speaks, from the same source of truth:

bash
# Base UI (the default)
npx shadcn@latest add https://ui.beste.co/r-base/hero7

# Radix, still first class
npx shadcn@latest add https://ui.beste.co/r/hero7

The block below is that idea in one frame. It is a real component from the registry, and it is the same source whether you install it as Base UI or as Radix.

Centered Hero with Media
View block
Loading preview…
hero7 from the registry. One source, generated into either Base UI or Radix at build time.

When to migrate, and when to wait

New project: take the default and start on Base UI. You get the newer primitives for free, and you are aligned with where the registry is heading.

Existing Radix app that is happy: there is no forced march. Radix keeps working, and here it keeps its own /r/ URLs that never change. Migrate when you have a reason: you want a primitive Base UI adds and Radix lacks (Combobox, Autocomplete, Number Field, Checkbox Group, object-valued or multiple Select), or you want to be on the same anatomy as everything you install next. When you do migrate, do it leaf first, one component per commit, and let the four shifts carry most of the work.

The names stayed. The wiring changed. Now you have the map for both.