shadcn-ui

migrate-radix-to-base

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.

shadcn-ui 122,683 10,060 Updated 2mo ago

Resources

9
GitHub

Install

npx skillscat add shadcn-ui/ui/migrate-radix-to-base

Install via the SkillsCat registry.

About this skill

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.

SKILL.md

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)

  1. 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.
  2. 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.
  3. Require a clean git tree; work on a branch; one commit per component.
  4. Baseline check BEFORE touching dependencies: run the project's
    typecheck/build so pre-existing failures are never attributed to you.
  5. 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:
    1. 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.
    2. 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).
    3. 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.
    4. 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.
    5. 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-<style> variant would restyle the user&#39;s app. Use<br>the radix golden ONLY to detect customizations, then run the transformation<br>engine on the user&#39;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-&gt;render with the<br>worked example; Portal&gt;Positioner&gt;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> &quot;Migrate accordion&quot; = one component, strangler-fig:</p> <ol> <li>Detect in-progress state first: an existing <code>&lt;component&gt;-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 -&gt;<br>button), STOP and recommend migrating those first, bottom-up.</li> <li>Write the migrated version to <code>&lt;component&gt;-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> -&gt; 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-&lt;style&gt;</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 -&gt; CSS aspect-ratio div; Label -&gt;<br>native <code>&lt;label&gt;</code>; VisuallyHidden -&gt; <code>sr-only</code>; Direction -&gt; 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/&lt;component&gt;.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&#39; files.</li> <li>A multi-component run (&quot;migrate alert-dialog and dropdown-menu&quot;) 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&#39;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 &quot;what&#39;s left&quot;. End every run&#39;s summary with that derived count<br>(&quot;N wrappers remain on Radix&quot;).</li> </ul> <p>Each <code>.migration/&lt;component&gt;.md</code> uses EXACTLY this structure (it is<br>documented publicly; reports must match it):</p> <pre><code class="language-md" data-language="md"># &lt;component&gt; &lt;date, strategy used (golden pair via CLI / merge / engine), one-line verdict&gt; ## Changed &lt;every file touched, with what changed and why; include file:line for anything notable. Confirm the leftover scan is clean: grep -n &quot;radix-ui\|@radix-ui&quot; on this component&#39;s files&gt; ## Left alone &lt;files that look related but were intentionally not touched, with the reason (cmdk/vaul/sonner are not radix; unrelated drift; etc.)&gt; ## Behavior changes &lt;differences that compile fine but act differently; flagged, never patched (tabs activation, menu close-on-click, delays...). Empty section if none&gt; ## Verify by hand &lt;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&gt;</code></pre>