---
title: "Radix to Base UI Migration Guide: Why Codemods Fail"
slug: "radix-to-base-ui-in-shadcn"
published: "2026-09-24"
updated: "2026-10-01"
categories:
  - "React"
tags:
  - "Radix to Base UI"
  - "shadcn migration"
  - "Radix UI"
  - "Base UI"
  - "codemod migration"
  - "headless UI primitives"
  - "asChild render prop"
  - "shadcn CLI"
  - "react-day-picker"
  - "component migration strategy"
  - "progressive refactor"
  - "use client"
  - "shadcn apply preset"
llm-intent: "how-to"
audience-level: "intermediate"
llm-purpose: "Radix to Base UI migration in shadcn: discover why automated codemods fail, how to migrate primitives safely, and checks to avoid breaking builds—read…"
llm-prereqs:
  - "shadcn"
  - "Radix UI"
  - "Base UI"
  - "React"
  - "TypeScript"
  - "react-day-picker"
  - "shadcn CLI"
---

**Summary Triples**
- (Radix to Base UI Migration Guide: Why Codemods Fail, expresses-intent, how-to)
- (Radix to Base UI Migration Guide: Why Codemods Fail, covers-topic, Radix to Base UI)
- (Radix to Base UI Migration Guide: Why Codemods Fail, provides-guidance-for, Radix to Base UI migration in shadcn: discover why automated codemods fail, how to migrate primitives safely, and checks to avoid breaking builds—read…)

### {GOAL}
Radix to Base UI migration in shadcn: discover why automated codemods fail, how to migrate primitives safely, and checks to avoid breaking builds—read…

### {PREREQS}
- shadcn
- Radix UI
- Base UI
- React
- TypeScript
- react-day-picker
- shadcn CLI

### {STEPS}
1. Assess component ownership
2. Identify leaf primitives first
3. Fix aliases and switch the base
4. Migrate primitives incrementally
5. Typecheck, then deploy a preview
6. Handle CLI & utility gotchas

<!-- llm:goal="Radix to Base UI migration in shadcn: discover why automated codemods fail, how to migrate primitives safely, and checks to avoid breaking builds—read…" -->
<!-- llm:prereq="shadcn" -->
<!-- llm:prereq="Radix UI" -->
<!-- llm:prereq="Base UI" -->
<!-- llm:prereq="React" -->
<!-- llm:prereq="TypeScript" -->
<!-- llm:prereq="react-day-picker" -->
<!-- llm:prereq="shadcn CLI" -->

# Radix to Base UI Migration Guide: Why Codemods Fail
> Radix to Base UI migration in shadcn: discover why automated codemods fail, how to migrate primitives safely, and checks to avoid breaking builds—read…
Matija Žiberna · 2026-09-24

I recently spun up a new project with shadcn and was genuinely surprised to see the notice announcing Base UI as the new default primitive library. Like many developers, the vast majority of my client projects over the last couple of years have been running on Radix UI. Naturally, my first instinct was to look into porting an existing project over. I found several community codemods and upgrade scripts promising automated one-click translation, but the moment I tested them on a real codebase, the reality set in: documentation codemods work fine on pristine starter templates, but they fail completely when faced with real-world tailored components. This guide covers what the Radix to Base UI shift in shadcn actually entails, why automated codemods fall short on customized primitives, and how to execute a safe, progressive upgrade.

### What It Is

When shadcn announced Base UI as the default component library, the immediate reaction across the ecosystem was to look for a migration script. But shadcn is not an npm package you import as an immutable dependency; it is code copied directly into your repository. You own the component files in your project. You add variants, change class names, wire custom handlers, and inject project-specific helpers.

Because of this architecture, a traditional AST codemod cannot reliably transform your code. A codemod works well on pristine templates, but it fails the moment it encounters human customizations. Instead of shipping a brittle script, shadcn introduced two mechanisms: multi-base support in the CLI for initializing or reinstalling styled primitives, and a dedicated AI migration skill that reads both implementations, compares the diffs against pristine golden pairs, and carries your customizations forward progressively.

### The Mental Model: Tailoring a Bespoke Suit

Think of your shadcn component directory like a collection of tailored suits rather than off-the-rack clothing. Radix provided the initial fabric and pattern. Over months of building your application, you hemmed the pants, adjusted the sleeves, and added custom pockets. 

A traditional codemod acts like an automated cutting machine programmed strictly for the original factory pattern. If you run it over a tailored suit, it cuts straight through your custom seams and ruins the garment. To change the underlying fabric to Base UI without destroying the tailoring, you must measure your specific alterations, understand how the new material behaves, and apply the adjustments seam by seam.

In code, this difference is immediately visible at the boundary between primitive wrappers and consumer call sites. In Radix, polymorphism is driven by the `asChild` prop:

```tsx
// src/components/ui/button.tsx (Radix)
const Comp = asChild ? Slot : "button"
return <Comp className={cn(buttonVariants({ variant, size }))} {...props} />
```

In Base UI, that entire mechanism is replaced by the `render` prop:

```tsx
// src/components/ui/button.tsx (Base UI)
return (
  <ButtonPrimitive
    render={render}
    className={cn(buttonVariants({ variant, size }))}
    {...props}
  />
)
```

Because `asChild` expects a single React element passed as a child while `render` accepts an element or a render function directly on the prop, changing the primitive under the hood ripples out to every call site in your application that relied on the previous convention.

### When To Use It

Reaching for Base UI or switching your project base makes sense in very specific situations.

If you are bootstrapping a new project or building a greenfield feature set where you want modern primitive features like built-in field validation states, native transition hooks, or unstyled positioning that separates the viewport from the popup box, Base UI is the right default. 

It is also appropriate if you are maintaining an existing project and decide to execute a deliberate, progressive component migration using the official skill. In that scenario, you migrate foundational leaf primitives first, such as buttons and labels, and verify that your application typechecks and renders correctly after each step.

Before undertaking a primitive upgrade across your UI layer, make sure you actually need to own the underlying behavior. If you are weighing whether to adopt headless primitives like Radix and Base UI or keep hand-rolling interaction logic, read [Headless UI Primitives: When to Own UI Behavior Today](/blog/headless-ui-primitives-when-to-own-ui-behavior). It outlines a pragmatic framework for identifying invisible interaction risks—such as focus traps, keyboard navigation, and ARIA state—before committing engineering time to component architecture.

### When NOT To Use It

You should avoid migrating to Base UI if your production application is already stable, thoroughly tested, and running on Radix primitives without issue. 

Radix UI is not deprecated. It remains officially supported by shadcn, and its primitives have been battle-tested across millions of production interfaces. If you have an established app with dozens of complex dialogs, navigation menus, and form controls, migrating simply because the default changed adds risk with very little tangible benefit to your end users.

Equally important, never attempt to migrate by deleting your `components/ui` folder and running an unconditional CLI re-initialization. Doing so wipes out all custom variants, resets accessibility adjustments, and immediately introduces breaking type mismatches across every page in your repository.

### Gotchas and Common Mistakes

These come from migrating a real Next.js app with `shadcn apply --preset`. Most were not caught by the TypeScript compiler.

#### A wrong `aliases.utils` creates bare `"cn"` imports

The CLI writes `cn` imports based on `aliases.utils` in `components.json`. In this repo the alias pointed at `@/lib/utils`, but `cn` actually lived in `@/lib/helpers/utils`. The CLI could not find it, so it generated `from "cn"` (the bare npm package) imports in 51 files, added a `cn` dependency, and created a new `src/lib/utils.ts`:

```typescript
// src/lib/utils.ts (created by the CLI)
export { cn } from "cn"
```

It did not overwrite an existing file here, but nothing guarantees that if your `utils.ts` already holds real helpers. Set the alias to the true location before running `apply`:

```json
"aliases": {
  "utils": "@/lib/helpers/utils"
}
```

If it already happened, rewrite the `from "cn"` imports to your real path, delete the stray file, and remove the `cn` package. Either way, read your git diff before you commit.

#### Switching the base goes through `style`

A top-level `"base": "base"` key made `components.json` invalid. What worked was setting `style` to `base-nova`, after which `shadcn info` reported `base: base`. Then `shadcn apply --preset <code>` rewrote the style again (to `base-mira` in our case).

```json
"style": "base-nova"
```

#### A preset changes more than the base

Applying a preset is not only a Radix-to-Base-UI swap. In this repo the same commit also changed the icon library from lucide to phosphor, `baseColor` from neutral to zinc, `react-day-picker` from 9.14 to 10.0.2, and `shadcn` to 4.21, and it added `@base-ui/react`. Expect icon imports and calendar props to change along with the primitives, and review those diffs separately.

#### `"use client"` failures only show up in a real build

The regenerated `breadcrumb.tsx` uses `useRender` and shipped without a `"use client"` directive. Rendered from a server page, it crashed prerendering with `createContext is not a function`. The opposite mistake also bites: adding `"use client"` to `button.tsx` broke every `buttonVariants()` call made from a server component, because the export becomes a client reference.

```tsx
// breadcrumb.tsx: wrappers built on useRender need it
"use client"

import { useRender } from "@base-ui/react/use-render"
```

The rule that worked: add the directive to `useRender` wrappers (badge, breadcrumb, button-group, item), and never to files whose exports, like `buttonVariants`, are called on the server. `tsc` passed in both broken states. The failures only appeared in a Vercel build.

#### Select needs `items`

Base UI's Select maps values to labels through an `items` prop. Without it, the trigger displays the raw value instead of the label. `onValueChange` also receives `string | null`, so your handlers need to deal with `null`.

```tsx
<Select
  value={currentLocale}
  items={languages.map((l) => ({ label: l.code.toUpperCase(), value: l.code }))}
  onValueChange={(value) => value !== null && handleLanguageChange(value)}
>
```

#### Other runtime-only breaks

These compiled fine or failed only at runtime:

- `data-[state=open]` selectors on collapsible and accordion triggers become `data-[panel-open]` (`group-data-[panel-open]:rotate-180` in our chevrons).
- `Accordion type="single" collapsible` is gone. Remove those props.
- `form.tsx` was not regenerated, so it still imported Radix `Slot` and `Label`. `FormControl` needed a rewrite around `React.cloneElement`.
- When `render` is not a native button (for example `render={<Link href="/contact" />}`), add `nativeButton={false}`. This does not apply to `HoverCardTrigger`.

```tsx
<Button render={<Link href="/contact" />} nativeButton={false}>
  Contact
</Button>
```

#### Menus and overlays split into parts

Radix handles collisions, positioning, and content in a single `Content` component. Base UI splits this into `Portal`, `Positioner`, and `Popup`. If you swap wrappers without accounting for how your app passes alignment offsets or portal containers, dropdowns can compile but render detached from their triggers.

#### Converting `asChild` call sites

We converted 61 `asChild` call sites to `render` with a small script built on the TypeScript compiler API. It handled the single-child case (`<Button asChild><Link /></Button>`) mechanically. Only a conditional-child case in `cart-sheet.tsx` needed manual work. This fits the article's theme: scripts work when the shape is regular, and the irregular cases are yours to fix.

### A Migration Checklist

1. Create a branch and back up `src/components/ui`.
2. Find your real customizations with `git log -- src/components/ui`. In our case only `breadcrumb.tsx` had local edits (accessibility commits), and reading the log was faster than diffing every component.
3. Fix `aliases.utils` in `components.json` so it points at the file that exports `cn`.
4. Set the base through `style` (for example `base-nova`).
5. Run `shadcn apply --preset <code>` and review the full diff, including the icon library, base color, and dependency changes.
6. Repair any bare `"cn"` imports, delete the stray `utils.ts`, and remove the `cn` package.
7. Convert `asChild` to `render`, adding `nativeButton={false}` where the target is not a button.
8. Fix Select `items`, Accordion props, ToggleGroup props, and `data-state` selectors.
9. Add `"use client"` to `useRender` wrappers only.
10. Run `npx tsc --noEmit`, then deploy a preview. Typechecking alone missed the `use client` failures.

### Conclusion

The transition from Radix UI to Base UI in shadcn represents a fundamental shift in how modern headless components are structured, but it does not mean your existing codebase needs an emergency rewrite. You own your code, which makes automated codemods impractical. Reach for Base UI when building new projects or when a specific primitive feature solves an active problem, and leave your stable Radix foundations alone if your application is already shipping reliably.

If you do migrate, the cost depends on how much you have customized. A small, mostly stock `components/ui` (ours had one customized file) took about an afternoon. A heavily customized one is where the real cost sits, because every local edit has to be found and carried forward by hand.

If you have questions or ran into a different gotcha, drop a comment below. And if you found this useful, subscribe for more.

Thanks,
Matija