Skip to content

Commit 438022c

Browse files
committed
chore: add shadcn agent skills
1 parent 6fbb36d commit 438022c

28 files changed

Lines changed: 5185 additions & 0 deletions
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
---
2+
name: migrate-radix-to-base
3+
description: 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+
8+
You migrate shadcn wrappers, hand-rolled radix compositions, and their
9+
consumers to `@base-ui/react`, keeping the project buildable at every step.
10+
Be precise; never guess a mapping. When a prop or part is not in these
11+
reference files, check `node_modules/@base-ui/react/**/*.d.ts` before
12+
transforming, and record gaps in the report.
13+
14+
## Preflight (always)
15+
16+
1. `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.
19+
2. 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.
22+
3. Require a clean git tree; work on a branch; one commit per component.
23+
4. Baseline check BEFORE touching dependencies: run the project's
24+
typecheck/build so pre-existing failures are never attributed to you.
25+
5. 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:
86+
1. 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.
89+
2. If the component imports other ui wrappers still on radix (select ->
90+
button), STOP and recommend migrating those first, bottom-up.
91+
3. 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
100+
dependency order (leaf/shared wrappers like button and label first). After
101+
wrappers, sweep ALL app code against `consumer-props.md` — the call-site
102+
break surface is much larger than asChild. Then remove radix deps, install,
103+
full 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+
124+
Typecheck per file, build per batch, full build at the end vs the baseline.
125+
126+
Reports live in a `.migration/` directory at the project root, ONE FILE PER
127+
COMPONENT: `.migration/<component>.md` (e.g. `.migration/accordion.md`).
128+
Rules:
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+
144+
Each `.migration/<component>.md` uses EXACTLY this structure (it is
145+
documented 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
155+
anything notable. Confirm the leftover scan is clean:
156+
grep -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
171+
dialogs, keyboard nav + typeahead on menus/select, tooltip delay feel,
172+
slider commit events. Concrete steps, one minute of clicking>
173+
```
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Class-string rewrites (layer 2)
2+
3+
Apply these across ALL class strings (className, cva definitions, cn calls),
4+
including app code. They are safe, mechanical rewrites.
5+
6+
## Data-attribute selectors
7+
8+
| Radix pattern | Base UI pattern |
9+
|---|---|
10+
| `data-[state=open]:` | `data-open:` |
11+
| `data-[state=closed]:` | `data-closed:` |
12+
| `data-[state=checked]:` | `data-checked:` |
13+
| `data-[state=unchecked]:` | `data-unchecked:` |
14+
| `data-[state=active]:` (tabs) | `data-active:` |
15+
| `data-[state=on]:` (toggle) | `data-pressed:` |
16+
| `data-[highlighted]:` | `data-highlighted:` (unchanged) |
17+
| `data-[disabled]:` | `data-disabled:` (unchanged) |
18+
| `data-[side=...]:` | `data-[side=...]:` (unchanged, still parameterized) |
19+
| `group-data-[state=open]` / `peer-data-[state=open]` | `group-data-open` / `peer-data-open` |
20+
| submenu trigger open marker `data-[state=open]:` | `data-popup-open:` |
21+
22+
## Animation idiom
23+
24+
Radix (tw-animate/keyframes):
25+
`data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0`
26+
27+
Base UI (transition + starting/ending styles):
28+
`transition-[opacity,transform] data-starting-style:opacity-0 data-ending-style:opacity-0` (plus translate/scale equivalents).
29+
30+
Do not translate animate-in/out utilities 1:1; restate the intent with
31+
`data-starting-style:` / `data-ending-style:` transitions. When the original
32+
uses per-side slide classes, keep the `data-[side=...]` or
33+
`data-[swipe-direction=...]` parameterization.
34+
35+
## CSS variables
36+
37+
| Radix var | Base UI var |
38+
|---|---|
39+
| `--radix-<comp>-content-transform-origin` | `--transform-origin` |
40+
| `--radix-<comp>-content-available-height` | `--available-height` |
41+
| `--radix-<comp>-content-available-width` | `--available-width` |
42+
| `--radix-<comp>-trigger-width` | `--anchor-width` |
43+
| `--radix-<comp>-trigger-height` | `--anchor-height` |
44+
| `--radix-accordion-content-height` | `--accordion-panel-height` |
45+
| `--radix-collapsible-content-height` | `--collapsible-panel-height` |
46+
| `--radix-navigation-menu-viewport-height/width` | `--positioner-height` / `--positioner-width` |
47+
48+
## Element changes kill pseudo-class variants
49+
50+
When a part's rendered element changes from a form control to a generic
51+
element (checkbox/switch/radio Roots render `<span>` in Base UI), `disabled:`
52+
and `:disabled` Tailwind variants become dead code. Replace them with
53+
`data-disabled:` equivalents. (Note: the shadcn base registry's checkbox
54+
still carries the dead `disabled:*` classes; treat that as an upstream quirk,
55+
not a pattern to copy.)
56+
57+
## Disabled-state hooks
58+
59+
Some Base UI triggers surface disabled state as `aria-disabled` rather than
60+
the `disabled` attribute (accordion trigger, tabs tab). Where the radix code
61+
used `disabled:opacity-50`, add or substitute `aria-disabled:opacity-50`
62+
according to the wrapper's reference file.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Consumer-side prop changes (call sites, not wrappers)
2+
3+
The shadcn wrapper NAMES survive a radix -> base-ui migration, but these props
4+
change or disappear at CALL SITES in app code. Sweep every consumer for this
5+
list after migrating the wrappers. All entries verified against
6+
@base-ui/react@1.6.0 type definitions during real migrations; when in doubt,
7+
check node_modules/@base-ui/react/**/*.d.ts, never guess.
8+
9+
## Universal
10+
11+
| Radix | Base UI | Call-site action |
12+
|---|---|---|
13+
| `asChild` (any wrapper) | `render` prop | `<Trigger asChild><Button/></Trigger>` -> `<Trigger render={<Button/>}>...` |
14+
15+
## Per component
16+
17+
| Component | Radix prop | Base UI fate | Call-site action |
18+
|---|---|---|---|
19+
| Accordion | `type="single"\|"multiple"` + `collapsible` | dropped; `value`/`defaultValue` are ALWAYS arrays; multiple-open via `multiple` | `type="single" collapsible` -> remove both; wrap values in arrays; `type="multiple"` -> `multiple` |
20+
| Tabs | `activationMode="manual"` | dropped; Base UI defaults to MANUAL activation | remove prop; near-equivalent opt-in is `Tabs.List activateOnFocus` (behavior delta: flag, do not auto-add) |
21+
| Select | `position="popper"\|"item-aligned"` | `alignItemWithTrigger` boolean (on Positioner; wrappers expose it) | `position="popper"` -> `alignItemWithTrigger={false}`; `item-aligned` -> `alignItemWithTrigger` (default) |
22+
| TooltipProvider | `delayDuration`, `skipDelayDuration` | `delay`; skip-delay concept dropped | rename / remove |
23+
| Tooltip | `disableHoverableContent` | NO equivalent | remove; FLAG the behavior change in the report |
24+
| Avatar.Image | `delayMs` | `delay` | rename |
25+
| ScrollArea | `type="always"\|"scroll"\|...` | dropped | remove |
26+
| Separator | `decorative` | dropped | remove |
27+
| Checkbox | `checked="indeterminate"` | `indeterminate` is a SEPARATE boolean prop | `checked="indeterminate"` -> `indeterminate` + boolean `checked` |
28+
| Slider | `onValueChange(value)` | signature gains event details; also `inverted` REMOVED | check handler arity; remove `inverted` (flag vertical-inverted usage) |
29+
| Select | `onValueChange(value: string)` | widens to `(value: Value \| null, eventDetails)` | `useState<string>` + `onValueChange={setState}` breaks: widen state to `string \| null` or wrap the setter |
30+
| Slider | `onValueCommit` | `onValueCommitted` | rename |
31+
| ToggleGroup | `type="single"\|"multiple"` | `multiple` boolean; value shape arrays | same treatment as Accordion |
32+
| ToggleGroup / Toolbar | `rovingFocus={false}` | dropped (roving focus always on); `loop` -> `loopFocus` | remove / rename |
33+
| Menubar | `value`/`onValueChange` (active menu) | dropped; control per Menu.Root `open` | restructure if used; usually unused |
34+
| Menubar | `loop` | `loopFocus` | rename |
35+
| ContextMenu.Root | `modal` | REMOVED | remove |
36+
| ContextMenu.Trigger | `disabled` | REMOVED | remove; gate the trigger yourself |
37+
| DropdownMenu/ContextMenu items | (Radix closed menu on select) | `closeOnClick` defaults FALSE on CheckboxItem/RadioItem | behavior delta: flag; add `closeOnClick` only if the user asks |
38+
| NavigationMenu | `delayDuration`(200), `skipDelayDuration`, `viewport` | `delay`(50) + `closeDelay`; viewport prop gone (Positioner handles it) | rename/remove; flag the 200->50 hover-delay feel change |
39+
| Popover / HoverCard | `openDelay`/`closeDelay` on Root | move to TRIGGER as `delay`/`closeDelay` | relocate props Root -> Trigger |
40+
| Dialog / AlertDialog | `onOpenAutoFocus` | `initialFocus` (element/ref-based, not event-based) | restructure: pass target instead of preventDefault handler |
41+
| Dialog / AlertDialog | `onCloseAutoFocus` | `finalFocus` | same restructure |
42+
| Dialog family | `onEscapeKeyDown`, `onPointerDownOutside`, `onInteractOutside` | consolidated; see the overlays reference for exact per-part signatures | consult overlays.md; do not guess |
43+
| DirectionProvider | `dir` | `direction` | rename |
44+
45+
## Callback signature rule
46+
47+
Base UI callbacks commonly gain an event-details argument:
48+
`onOpenChange(open, eventDetails)`, `onValueChange(value, eventDetails)`.
49+
Passing an existing single-arg handler stays type-safe; handlers that USED
50+
Radix's event parameter need review against the family reference file.
51+
52+
## Sweep procedure
53+
54+
1. grep app code (outside components/ui) for each LHS token above plus
55+
`asChild`.
56+
2. Fix call sites file by file; typecheck after each file.
57+
3. Anything on this list marked FLAG goes into the migration report as a
58+
behavior delta, never silently patched.

0 commit comments

Comments
 (0)