Radix UI -> Base UI migration
You migrate shadcn wrappers, hand-rolled radix compositions, and their
consumers to @base-ui/react, keeping the project buildable at every step.
Be precise; never guess a mapping. When a prop or part is not in these
reference files, check node_modules/@base-ui/react/**/*.d.ts before
transforming, and record gaps in the report.
Preflight (always)
npx shadcn@latest info --json (or the project's runner): gives the
current base, STYLE (e.g. radix-lyra), tailwind version, aliases,
installed components, and package manager. Trust it over inference.
- Detect the package manager (packageManager field / lockfile:
pnpm-lock.yaml, bun.lock, yarn.lock, package-lock.json) and use IT for
every install. Never leave a stale lockfile.
- Require a clean git tree; work on a branch; one commit per component.
- Baseline check BEFORE touching dependencies: run the project's
typecheck/build so pre-existing failures are never attributed to you.
- Install
@base-ui/react alongside radix. Radix packages are removed only
after the LAST component is migrated (both coexist fine).
Strategy: golden pair first, transformation engine second
- Golden pair via the CLI (preferred). If the project is shadcn with a
known style (
radix-<style>), the shadcn CLI itself is the golden-pair
executor:
- Classify each ui wrapper FIRST: diff the user's file against its stock
origin, using the components.json style VERBATIM in the URL
(
https://ui.shadcn.com/r/styles/<style>/<component>.json,
files[0].content). This works for prefixed styles (radix-nova) AND
legacy unprefixed ones (new-york, new-york-v4, default), which are all
still served.
- WHOLE-PROJECT mode: flip
components.json style radix-<style> ->
base-<style> now. PROGRESSIVE mode: do NOT flip yet (the project is
still mostly radix; the flip happens once, after the last component);
fetch base variants directly by URL instead
(https://ui.shadcn.com/r/styles/base-<style>/<component>.json).
- PRISTINE wrappers, whole-project mode:
shadcn add <component> --overwrite delivers the base variant with the project's exact
icon/font/preset resolution. Never bulk --all --overwrite; go
component by component, or you drown in unrelated registry version
drift. PROGRESSIVE mode: never use --overwrite (it destroys the
original that consumers still import); write the fetched base variant
content to <component>-base.tsx instead.
- CUSTOMIZED wrappers: fetch the base variant and replay the user's diff
onto it (their customizations must SURVIVE;
--overwrite would destroy
them). Mechanical implementation that works at scale:
git merge-file user.tsx radix-golden.tsx base-golden.tsx (three-way
merge, radix golden as ancestor) auto-resolves most files; hand-resolve
conflicts with the reference tables.
- MANDATORY leftover sweep on EVERY golden-pair file, including ones that
merged "clean":
grep -n "radix-ui\|@radix-ui\|IconPlaceholder" per
file. The registry sometimes reorders functions between variants, which
makes three-way merges report zero conflicts while leaving stale radix
hunks in place. A clean merge is NOT proof of a clean file.
This is more reliable than reconstructing transforms; use it whenever the
pair exists. Consumer/app code has no CLI mechanism: always hand-migrate it
against consumer-props.md.
- Legacy styles (new-york, new-york-v4, default): classification only, no
replay. These have no base counterpart (there is no base-new-york), and
retargeting onto a base- variant would restyle the user's app. Use
the radix golden ONLY to detect customizations, then run the transformation
engine on the user's OWN file: rewire primitives, keep their exact classes,
apply class-mapping renames. Their look stays theirs. At the end of a
legacy whole-project migration, FLAG (do not fix): the style name still
reads as radix to the CLI, so future
shadcn add will deliver radix
variants; the user decides whether to switch style or add manually.
- Transformation engine (fallback). Hand-rolled radix code, non-shadcn
projects, unknown styles: transform using
universal-patterns.md (imports
in BOTH forms: radix-ui and @radix-ui/react-*; asChild->render with the
worked example; Portal>Positioner>Popup; the positioner FORWARD rule; part
renames), the per-family props tables (overlays.md, menus.md,
form-controls.md, disclosure.md, display-misc.md), class-mapping.md
for data-attribute/CSS-var rewrites, and wrapper-shapes.md for exact
target shapes (tooltip arrow, SubContent defaults, select anatomy).
Modes
Progressive (default). "Migrate accordion" = one component, strangler-fig:
- Detect in-progress state first: an existing
<component>-base.tsx,
consumers split between old/new imports. The files ARE the state; resume,
never restart.
- If the component imports other ui wrappers still on radix (select ->
button), STOP and recommend migrating those first, bottom-up.
- Write the migrated version to
<component>-base.tsx (original untouched;
golden-pair content fetched by URL, or transformed by hand, per the
strategy above); typecheck. Repoint consumers ONE AT A TIME (imports + the
call-site props in consumer-props.md); typecheck each. When no consumer
imports the original: delete it, rename -base -> original, flip imports
back, final check, commit. When the LAST radix wrapper in the project is
finalized, flip components.json to base-<style> and remove radix deps.
Whole project (only when explicitly asked): same per-component work in
dependency order (leaf/shared wrappers like button and label first). After
wrappers, sweep ALL app code against consumer-props.md — the call-site
break surface is much larger than asChild. Then remove radix deps, install,
full build.
Hard rules
- NEVER touch non-radix libraries or their wrappers: cmdk (command), vaul
(drawer), sonner, input-otp, react-day-picker (calendar), recharts (chart).
Report them as intentionally untouched.
- No Base UI counterpart: AspectRatio -> CSS aspect-ratio div; Label ->
native
<label>; VisuallyHidden -> sr-only; Direction -> Direction
Provider (direction prop, not dir). Popover Anchor and NavigationMenu
Indicator have no equivalent: inert passthrough + flag.
button.tsx migrates to the REAL @base-ui/react/button primitive, never
a hand-rolled useRender wrapper.
- Behavior deltas are FLAGGED, never silently patched (tabs manual
activation, menu items not closing on click, nav-menu 50ms delay). The
target is idiomatic Base UI matching the shadcn base registry.
- Honest reporting: skipped/reverted files are listed as flagged, never as
migrated. Pre-existing failures are named as pre-existing.
Verify and report
Typecheck per file, build per batch, full build at the end vs the baseline.
Reports live in a .migration/ directory at the project root, ONE FILE PER
COMPONENT: .migration/<component>.md (e.g. .migration/accordion.md).
Rules:
- Each run writes (or fully overwrites) the file for each component it
migrated. Re-running a component replaces its report; never touch other
components' files.
- A multi-component run ("migrate alert-dialog and dropdown-menu") writes one
file per component, each self-contained; shared consumer-sweep notes are
repeated in every affected file.
- Whole-project mode writes the per-component files plus
.migration/project.md (dependency swap, app-code sweep summary, final
build result).
- There is NO index file. Migration status is derived from disk, not
maintained: scan the project's ui directory (the
ui alias from shadcn
info, e.g. components/ui or src/components/ui) for remaining radix imports
when asked "what's left". End every run's summary with that derived count
("N wrappers remain on Radix").
Each .migration/<component>.md uses EXACTLY this structure (it is
documented publicly; reports must match it):
# <component>
<date, strategy used (golden pair via CLI / merge / engine), one-line verdict>
## Changed
<every file touched, with what changed and why; include file:line for
anything notable. Confirm the leftover scan is clean:
grep -n "radix-ui\|@radix-ui" on this component's files>
## Left alone
<files that look related but were intentionally not touched, with the reason
(cmdk/vaul/sonner are not radix; unrelated drift; etc.)>
## Behavior changes
<differences that compile fine but act differently; flagged, never patched
(tabs activation, menu close-on-click, delays...). Empty section if none>
## Verify by hand
<short manual QA checklist for this primitive family: focus return on
dialogs, keyboard nav + typeahead on menus/select, tooltip delay feel,
slider commit events. Concrete steps, one minute of clicking>
1---2name: migrate-radix-to-base3description: Migrates React projects and components from Radix UI to Base UI. Use when asked to migrate from radix, move to base-ui, convert radix primitives, or switch a shadcn project's base library. Handles single components ("migrate accordion") and whole projects.4---5
6# Radix UI -> Base UI migration
7
8You migrate shadcn wrappers, hand-rolled radix compositions, and their
9consumers to `@base-ui/react`, keeping the project buildable at every step.
10Be precise; never guess a mapping. When a prop or part is not in these
11reference files, check `node_modules/@base-ui/react/**/*.d.ts` before
12transforming, and record gaps in the report.
13
14## Preflight (always)
15
161. `npx shadcn@latest info --json` (or the project's runner): gives the
17 current base, STYLE (e.g. `radix-lyra`), tailwind version, aliases,
18 installed components, and package manager. Trust it over inference.
192. Detect the package manager (packageManager field / lockfile:
20 pnpm-lock.yaml, bun.lock, yarn.lock, package-lock.json) and use IT for
21 every install. Never leave a stale lockfile.
223. Require a clean git tree; work on a branch; one commit per component.
234. Baseline check BEFORE touching dependencies: run the project's
24 typecheck/build so pre-existing failures are never attributed to you.
255. Install `@base-ui/react` alongside radix. Radix packages are removed only
26 after the LAST component is migrated (both coexist fine).
27
28## Strategy: golden pair first, transformation engine second
29
30- **Golden pair via the CLI (preferred).** If the project is shadcn with a
31 known style (`radix-<style>`), the shadcn CLI itself is the golden-pair
32 executor:
33 1. Classify each ui wrapper FIRST: diff the user's file against its stock
34 origin, using the components.json style VERBATIM in the URL
35 (`https://ui.shadcn.com/r/styles/<style>/<component>.json`,
36 files[0].content). This works for prefixed styles (radix-nova) AND
37 legacy unprefixed ones (new-york, new-york-v4, default), which are all
38 still served.
39 2. WHOLE-PROJECT mode: flip `components.json` style `radix-<style>` ->
40 `base-<style>` now. PROGRESSIVE mode: do NOT flip yet (the project is
41 still mostly radix; the flip happens once, after the last component);
42 fetch base variants directly by URL instead
43 (`https://ui.shadcn.com/r/styles/base-<style>/<component>.json`).
44 3. PRISTINE wrappers, whole-project mode: `shadcn add <component>
45 --overwrite` delivers the base variant with the project's exact
46 icon/font/preset resolution. Never bulk `--all --overwrite`; go
47 component by component, or you drown in unrelated registry version
48 drift. PROGRESSIVE mode: never use `--overwrite` (it destroys the
49 original that consumers still import); write the fetched base variant
50 content to `<component>-base.tsx` instead.
51 4. CUSTOMIZED wrappers: fetch the base variant and replay the user's diff
52 onto it (their customizations must SURVIVE; `--overwrite` would destroy
53 them). Mechanical implementation that works at scale:
54 `git merge-file user.tsx radix-golden.tsx base-golden.tsx` (three-way
55 merge, radix golden as ancestor) auto-resolves most files; hand-resolve
56 conflicts with the reference tables.
57 5. MANDATORY leftover sweep on EVERY golden-pair file, including ones that
58 merged "clean": `grep -n "radix-ui\|@radix-ui\|IconPlaceholder"` per
59 file. The registry sometimes reorders functions between variants, which
60 makes three-way merges report zero conflicts while leaving stale radix
61 hunks in place. A clean merge is NOT proof of a clean file.
62 This is more reliable than reconstructing transforms; use it whenever the
63 pair exists. Consumer/app code has no CLI mechanism: always hand-migrate it
64 against `consumer-props.md`.
65- **Legacy styles (new-york, new-york-v4, default): classification only, no
66 replay.** These have no base counterpart (there is no base-new-york), and
67 retargeting onto a base-<style> variant would restyle the user's app. Use
68 the radix golden ONLY to detect customizations, then run the transformation
69 engine on the user's OWN file: rewire primitives, keep their exact classes,
70 apply class-mapping renames. Their look stays theirs. At the end of a
71 legacy whole-project migration, FLAG (do not fix): the style name still
72 reads as radix to the CLI, so future `shadcn add` will deliver radix
73 variants; the user decides whether to switch style or add manually.
74- **Transformation engine (fallback).** Hand-rolled radix code, non-shadcn
75 projects, unknown styles: transform using `universal-patterns.md` (imports
76 in BOTH forms: `radix-ui` and `@radix-ui/react-*`; asChild->render with the
77 worked example; Portal>Positioner>Popup; the positioner FORWARD rule; part
78 renames), the per-family props tables (`overlays.md`, `menus.md`,
79 `form-controls.md`, `disclosure.md`, `display-misc.md`), `class-mapping.md`
80 for data-attribute/CSS-var rewrites, and `wrapper-shapes.md` for exact
81 target shapes (tooltip arrow, SubContent defaults, select anatomy).
82
83## Modes
84
85**Progressive (default).** "Migrate accordion" = one component, strangler-fig:
861. Detect in-progress state first: an existing `<component>-base.tsx`,
87 consumers split between old/new imports. The files ARE the state; resume,
88 never restart.
892. If the component imports other ui wrappers still on radix (select ->
90 button), STOP and recommend migrating those first, bottom-up.
913. Write the migrated version to `<component>-base.tsx` (original untouched;
92 golden-pair content fetched by URL, or transformed by hand, per the
93 strategy above); typecheck. Repoint consumers ONE AT A TIME (imports + the
94 call-site props in `consumer-props.md`); typecheck each. When no consumer
95 imports the original: delete it, rename `-base` -> original, flip imports
96 back, final check, commit. When the LAST radix wrapper in the project is
97 finalized, flip `components.json` to `base-<style>` and remove radix deps.
98
99**Whole project** (only when explicitly asked): same per-component work in
100dependency order (leaf/shared wrappers like button and label first). After
101wrappers, sweep ALL app code against `consumer-props.md` — the call-site
102break surface is much larger than asChild. Then remove radix deps, install,
103full build.
104
105## Hard rules
106
107- NEVER touch non-radix libraries or their wrappers: cmdk (command), vaul
108 (drawer), sonner, input-otp, react-day-picker (calendar), recharts (chart).
109 Report them as intentionally untouched.
110- No Base UI counterpart: AspectRatio -> CSS aspect-ratio div; Label ->
111 native `<label>`; VisuallyHidden -> `sr-only`; Direction -> Direction
112 Provider (`direction` prop, not `dir`). Popover Anchor and NavigationMenu
113 Indicator have no equivalent: inert passthrough + flag.
114- `button.tsx` migrates to the REAL `@base-ui/react/button` primitive, never
115 a hand-rolled useRender wrapper.
116- Behavior deltas are FLAGGED, never silently patched (tabs manual
117 activation, menu items not closing on click, nav-menu 50ms delay). The
118 target is idiomatic Base UI matching the shadcn base registry.
119- Honest reporting: skipped/reverted files are listed as flagged, never as
120 migrated. Pre-existing failures are named as pre-existing.
121
122## Verify and report
123
124Typecheck per file, build per batch, full build at the end vs the baseline.
125
126Reports live in a `.migration/` directory at the project root, ONE FILE PER
127COMPONENT: `.migration/<component>.md` (e.g. `.migration/accordion.md`).
128Rules:
129- Each run writes (or fully overwrites) the file for each component it
130 migrated. Re-running a component replaces its report; never touch other
131 components' files.
132- A multi-component run ("migrate alert-dialog and dropdown-menu") writes one
133 file per component, each self-contained; shared consumer-sweep notes are
134 repeated in every affected file.
135- Whole-project mode writes the per-component files plus
136 `.migration/project.md` (dependency swap, app-code sweep summary, final
137 build result).
138- There is NO index file. Migration status is derived from disk, not
139 maintained: scan the project's ui directory (the `ui` alias from shadcn
140 info, e.g. components/ui or src/components/ui) for remaining radix imports
141 when asked "what's left". End every run's summary with that derived count
142 ("N wrappers remain on Radix").
143
144Each `.migration/<component>.md` uses EXACTLY this structure (it is
145documented publicly; reports must match it):
146
147```md
148# <component>
149
150<date, strategy used (golden pair via CLI / merge / engine), one-line verdict>
151
152## Changed
153
154<every file touched, with what changed and why; include file:line for
155anything notable. Confirm the leftover scan is clean:
156grep -n "radix-ui\|@radix-ui" on this component's files>
157
158## Left alone
159
160<files that look related but were intentionally not touched, with the reason
161(cmdk/vaul/sonner are not radix; unrelated drift; etc.)>
162
163## Behavior changes
164
165<differences that compile fine but act differently; flagged, never patched
166(tabs activation, menu close-on-click, delays...). Empty section if none>
167
168## Verify by hand
169
170<short manual QA checklist for this primitive family: focus return on
171dialogs, keyboard nav + typeahead on menus/select, tooltip delay feel,
172slider commit events. Concrete steps, one minute of clicking>
173```