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.
Resources
9Install
npx skillscat add shadcn-ui/ui/migrate-radix-to-base Install via the SkillsCat registry.
This skill migrates React projects from Radix UI to Base UI by transforming both shadcn wrapper components and hand-rolled compositions into equivalent @base-ui/react code. It solves the problem of manually rewriting component APIs and prop mappings while keeping the build working throughout the migration. It should be used when a developer needs to migrate a single component or an entire project from Radix UI to Base UI.
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/reactalongside 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.jsonstyleradix-<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> --overwritedelivers 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.tsxinstead. - CUSTOMIZED wrappers: fetch the base variant and replay the user's diff
onto it (their customizations must SURVIVE;--overwritewould 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
againstconsumer-props.md.
- Classify each ui wrapper FIRST: diff the user's file against its stock
- 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-<style> variant would restyle the user's app. Use<br>the radix golden ONLY to detect customizations, then run the transformation<br>engine on the user's OWN file: rewire primitives, keep their exact classes,<br>apply class-mapping renames. Their look stays theirs. At the end of a<br>legacy whole-project migration, FLAG (do not fix): the style name still<br>reads as radix to the CLI, so future <code>shadcn add</code> will deliver radix<br>variants; the user decides whether to switch style or add manually.</li> <li><strong>Transformation engine (fallback).</strong> Hand-rolled radix code, non-shadcn<br>projects, unknown styles: transform using <code>universal-patterns.md</code> (imports<br>in BOTH forms: <code>radix-ui</code> and <code>@radix-ui/react-*</code>; asChild->render with the<br>worked example; Portal>Positioner>Popup; the positioner FORWARD rule; part<br>renames), the per-family props tables (<code>overlays.md</code>, <code>menus.md</code>,<br><code>form-controls.md</code>, <code>disclosure.md</code>, <code>display-misc.md</code>), <code>class-mapping.md</code><br>for data-attribute/CSS-var rewrites, and <code>wrapper-shapes.md</code> for exact<br>target shapes (tooltip arrow, SubContent defaults, select anatomy).</li> </ul> <h2>Modes</h2> <p><strong>Progressive (default).</strong> "Migrate accordion" = one component, strangler-fig:</p> <ol> <li>Detect in-progress state first: an existing <code><component>-base.tsx</code>,<br>consumers split between old/new imports. The files ARE the state; resume,<br>never restart.</li> <li>If the component imports other ui wrappers still on radix (select -><br>button), STOP and recommend migrating those first, bottom-up.</li> <li>Write the migrated version to <code><component>-base.tsx</code> (original untouched;<br>golden-pair content fetched by URL, or transformed by hand, per the<br>strategy above); typecheck. Repoint consumers ONE AT A TIME (imports + the<br>call-site props in <code>consumer-props.md</code>); typecheck each. When no consumer<br>imports the original: delete it, rename <code>-base</code> -> original, flip imports<br>back, final check, commit. When the LAST radix wrapper in the project is<br>finalized, flip <code>components.json</code> to <code>base-<style></code> and remove radix deps.</li> </ol> <p><strong>Whole project</strong> (only when explicitly asked): same per-component work in<br>dependency order (leaf/shared wrappers like button and label first). After<br>wrappers, sweep ALL app code against <code>consumer-props.md</code> — the call-site<br>break surface is much larger than asChild. Then remove radix deps, install,<br>full build.</p> <h2>Hard rules</h2> <ul> <li>NEVER touch non-radix libraries or their wrappers: cmdk (command), vaul<br>(drawer), sonner, input-otp, react-day-picker (calendar), recharts (chart).<br>Report them as intentionally untouched.</li> <li>No Base UI counterpart: AspectRatio -> CSS aspect-ratio div; Label -><br>native <code><label></code>; VisuallyHidden -> <code>sr-only</code>; Direction -> Direction<br>Provider (<code>direction</code> prop, not <code>dir</code>). Popover Anchor and NavigationMenu<br>Indicator have no equivalent: inert passthrough + flag.</li> <li><code>button.tsx</code> migrates to the REAL <code>@base-ui/react/button</code> primitive, never<br>a hand-rolled useRender wrapper.</li> <li>Behavior deltas are FLAGGED, never silently patched (tabs manual<br>activation, menu items not closing on click, nav-menu 50ms delay). The<br>target is idiomatic Base UI matching the shadcn base registry.</li> <li>Honest reporting: skipped/reverted files are listed as flagged, never as<br>migrated. Pre-existing failures are named as pre-existing.</li> </ul> <h2>Verify and report</h2> <p>Typecheck per file, build per batch, full build at the end vs the baseline.</p> <p>Reports live in a <code>.migration/</code> directory at the project root, ONE FILE PER<br>COMPONENT: <code>.migration/<component>.md</code> (e.g. <code>.migration/accordion.md</code>).<br>Rules:</p> <ul> <li>Each run writes (or fully overwrites) the file for each component it<br>migrated. Re-running a component replaces its report; never touch other<br>components' files.</li> <li>A multi-component run ("migrate alert-dialog and dropdown-menu") writes one<br>file per component, each self-contained; shared consumer-sweep notes are<br>repeated in every affected file.</li> <li>Whole-project mode writes the per-component files plus<br><code>.migration/project.md</code> (dependency swap, app-code sweep summary, final<br>build result).</li> <li>There is NO index file. Migration status is derived from disk, not<br>maintained: scan the project's ui directory (the <code>ui</code> alias from shadcn<br>info, e.g. components/ui or src/components/ui) for remaining radix imports<br>when asked "what's left". End every run's summary with that derived count<br>("N wrappers remain on Radix").</li> </ul> <p>Each <code>.migration/<component>.md</code> uses EXACTLY this structure (it is<br>documented publicly; reports must match it):</p> <pre><code class="language-md" data-language="md"># <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></code></pre>