Skill 01 · Migrate Radix To Base
Subchapter 1.8
universal-patterns.mdMarkdown14 KBView on GitHub
Sources: (1) mechanical diff of the 61 component pairs in
apps/v4/registry/bases/{radix,base}/ui/ (ground truth, authored by us),
(2) radix-ui@1.4.3 package exports, (3) base-ui.com docs index for
@base-ui/react@1.6.0. Built 2026-07-02 as the knowledge base for the
migration agent’s skills.
primitives/All radix-ui exports, classified for migration:
| Radix primitive | Base UI target | Class |
|---|---|---|
| Accordion | Accordion | direct (Content->Panel) |
| AlertDialog | Alert Dialog | restructured (Overlay->Backdrop, Content->Popup, Cancel->Close, Action dropped) |
| AspectRatio | none | missing: plain div + CSS aspect-ratio (--ratio var) |
| Avatar | Avatar | direct |
| Checkbox | Checkbox | direct (cleanest 1:1) |
| Collapsible | Collapsible | direct (Content->Panel) |
| ContextMenu | Context Menu | restructured (menu mapping) |
| Dialog | Dialog | restructured (Overlay->Backdrop, Content->Popup) |
| DropdownMenu | Menu | RENAMED + restructured (canonical menu mapping) |
| Form | Form + Field + Fieldset | restructured (split into three) |
| HoverCard | Preview Card | RENAMED + positioner model |
| Label | none | missing: native <label> (Field.Label inside forms) |
| Menubar | Menubar + Menu | restructured (menubar root only; menus delegate to Menu) |
| NavigationMenu | Navigation Menu | heavily restructured (Viewport -> Positioner/Popup/Viewport, Indicator->Icon) |
| Popover | Popover | positioner model (Anchor dropped; verify vs docs) |
| Progress | Progress | restructured (new Track/Label/Value parts, no manual transform) |
| RadioGroup | Radio Group + Radio | restructured (Item -> Radio.Root, two subpath imports) |
| ScrollArea | Scroll Area | direct (Scrollbar/Thumb renames) |
| Select | Select | restructured (Viewport->List, ScrollButtons->ScrollArrows, alignItemWithTrigger) |
| Separator | Separator | direct (callable; decorative dropped) |
| Slider | Slider | restructured (Range->Indicator, new Control, thumbAlignment) |
| Switch | Switch | direct (1:1) |
| Tabs | Tabs | direct (Trigger->Tab, Content->Panel) |
| Toast | Toast | restructured (not in our registry pairs; spec from docs; shadcn users mostly use sonner) |
| Toggle | Toggle | direct (callable) |
| ToggleGroup | Toggle Group + Toggle | direct (items use Toggle primitive) |
| Toolbar | Toolbar | direct-ish (not in our pairs; spec from docs) |
| Tooltip | Tooltip | positioner model (delayDuration->delay on Provider) |
| unstable_OneTimePasswordField | OTP Field | from docs (our registry uses input-otp instead) |
| unstable_PasswordToggleField | none | missing: Input + custom toggle |
Utilities:
| Radix utility | Base UI equivalent |
|---|---|
| Slot / asChild | render prop; useRender + mergeProps for the manual Slot idiom |
| Portal | none standalone; per-component Portal parts |
| VisuallyHidden | none; sr-only class |
| AccessibleIcon | none; aria-label + sr-only text |
| Direction | Direction Provider |
Base UI-only (new capabilities, NOT migration targets): Autocomplete, Combobox, Input, Number Field, Checkbox Group, Meter, Filter, CSP Provider.
CORRECTION (dry-run finding): Base UI also ships a Button primitive
(@base-ui/react/button) that supports render. A shadcn button.tsx using
the Slot/asChild idiom migrates to <ButtonPrimitive> directly, NOT to a
hand-rolled useRender wrapper. useRender + mergeProps remains correct for
non-button polymorphic components (breadcrumb link, marker).
Never touched by migration (third-party on both sides): cmdk (command), vaul* (drawer; see drawer section: our base drawer moved vaul -> @base-ui/react/drawer), sonner, input-otp, react-day-picker (calendar), recharts (chart).
Radix appears in TWO import forms; both map to the same Base UI subpath:
import { X as XPrimitive } from "radix-ui" ->
import { X as XPrimitive } from "@base-ui/react/<kebab-name>".import * as XPrimitive from "@radix-ui/react-<name>" ->
import { X as XPrimitive } from "@base-ui/react/<kebab-name>".
(The namespace * as import becomes a named import; remove the individual
@radix-ui/react-* package from package.json.)
One subpath per component either way.React.ComponentProps<typeof XPrimitive.Part> -> XPrimitive.Part.Props.
Positioner props via Pick<XPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">.XPrimitive.Root -> XPrimitive
(separator, toggle, toggle-group root, radio-group root, menubar root).<Primitive.Close asChild><Button/></Primitive.Close> ->
<Primitive.Close render={<Button/>}>...</Primitive.Close>.const Comp = asChild ? Slot.Root : "a") ->
useRender + mergeProps from @base-ui/react/use-render /
@base-ui/react/merge-props; prop type useRender.ComponentProps<"a">.Portal > Content, positioning props on Content.Portal > Positioner > Popup. side, sideOffset, align,
alignOffset (and select’s alignItemWithTrigger) move to Positioner;
Popup is the styled box. Positioner conventionally gets isolate z-50.Overlay -> Backdrop (dialogs, sheets, drawers). Centered modals
(dialog/alert-dialog) use Popup WITHOUT a Positioner.data-[state=open] -> data-open; data-[state=closed] -> data-closed.data-[state=open]:animate-in /
data-[state=closed]:animate-out -> data-starting-style:* /
data-ending-style:* (transition-based, not keyframes).data-popup-open (open-submenu/trigger marker).aria-disabled:* variants alongside disabled:*
(accordion, tabs).--radix-<comp>-content-transform-origin -> --transform-origin--radix-<comp>-content-available-height -> --available-height--radix-<comp>-trigger-width -> --anchor-width--radix-accordion-content-height -> --accordion-panel-height--radix-navigation-menu-viewport-height/width ->
--positioner-height/width, --popup-height/width, --available-widthdelayDuration -> delay.position="popper"|"item-aligned" -> alignItemWithTrigger boolean.thumbAlignment (“edge”); Range -> Indicator + new Control.viewport boolean dropped; align forwarded to Positioner.value / defaultValue / onOpenChange signatures pass through unchanged at
the wrapper level (verify per-primitive callback signatures against docs when
authoring specs; wrappers do not exercise them all).| radix part | Base UI part |
|---|---|
*.Root (single-part comps) | callable *Primitive |
Overlay | Backdrop |
Content (overlay comps) | Popup (inside Positioner) |
Content (accordion/collapsible/tabs) | Panel |
tabs Trigger | Tab |
menu Label | GroupLabel |
menu ItemIndicator | CheckboxItemIndicator / RadioItemIndicator |
Sub / SubTrigger | SubmenuRoot / SubmenuTrigger |
slider Range | Indicator (+ new Control) |
select Viewport | List |
select ScrollUp/DownButton | ScrollUp/DownArrow |
scroll-area ScrollAreaScrollbar / ScrollAreaThumb | Scrollbar / Thumb |
nav-menu Indicator | Icon |
nav-menu Viewport | Positioner > Popup > Viewport |
hover-card HoverCard* | PreviewCard* |
radio-group Item / Indicator | Radio.Root / Radio.Indicator |
popover Anchor | dropped (verify against docs) |
alert-dialog Cancel / Action | Close / dropped (plain Button) |
separator decorative prop | dropped |
| Label primitive | native <label> |
Root/Item/Header/Trigger same; Content -> Panel. Trigger disabled:* ->
aria-disabled:*. Height var -> --accordion-panel-height; add
data-starting-style:h-0 data-ending-style:h-0.
Overlay -> Backdrop, Content -> Popup, Close kept (asChild -> render).
Alert-dialog: Cancel -> Close; Action has no primitive (plain Button).
Sheet: slide animations rewritten from animate-in/out to
data-starting-style / data-ending-style with explicit translate per
data-[side=...]. Centered modals: no Positioner.
Vaul is NOT radix: during a radix -> base-ui migration, leave drawer.tsx
untouched and report it (hard rule in SKILL.md). This mapping exists only for
when the user EXPLICITLY asks to also move their drawer off vaul.
Root gains modal, snapPoints, swipeDirection (default “down”),
showSwipeHandle. Content (single) -> Viewport > Popup > Content.
data-[vaul-drawer-direction=...] -> data-[swipe-direction=...] /
data-[swipe-axis=...] + --drawer-* vars. New SwipeHandle part and a
context provider in our wrapper. This is a vaul migration, not radix.
Portal > Positioner > Popup. Popover: Anchor dropped, Title is now a real
primitive part. Tooltip: Provider delayDuration -> delay; Content gains
side/align/alignOffset; default sideOffset 0 -> 4; Arrow gets explicit
per-side positioning classes. HoverCard: primitive renamed PreviewCard
(public wrapper names stay HoverCard*).
Canonical mapping: Label -> GroupLabel, ItemIndicator ->
CheckboxItemIndicator/RadioItemIndicator, Sub -> SubmenuRoot, SubTrigger ->
SubmenuTrigger, Content -> Portal > Positioner > Popup, SubContent rebuilt
from the Content component. Content hoists align/alignOffset/side/sideOffset.
SubTrigger open marker: data-popup-open. Context-menu has its own subpath
(@base-ui/react/context-menu), same anatomy. Menubar: only the root and
checkbox/radio items are menubar/menu primitives; everything else delegates
to the Menu wrappers (radix Menubar.Menu -> Menu.Root).
Label -> GroupLabel, Viewport -> List, ScrollUp/DownButton ->
ScrollUp/DownArrow. Icon/ItemIndicator go asChild -> render.
position -> alignItemWithTrigger (default true) on Positioner. Vars ->
--available-height / --anchor-width / --transform-origin.
Checkbox: 1:1. Switch: 1:1. Radio group: group from
@base-ui/react/radio-group (callable), items from @base-ui/react/radio
(Radio.Root + Radio.Indicator). Slider: Root > Control > Track > Indicator + Thumbs, thumbAlignment="edge"; layout classes move Root ->
Control. Toggle/toggle-group: callable primitives; group items reuse Toggle.
Tabs: Trigger -> Tab, Content -> Panel, aria-disabled:* added. Collapsible:
Content -> Panel. Progress: new Track/Label/Value parts; primitive computes
fill (drop the manual translateX). Separator: callable, decorative dropped.
Scroll-area: Scrollbar/Thumb renames only. Label: no primitive; native
<label>.
Viewport moves out of Root into Portal > Positioner > Popup > Viewport
(our NavigationMenuPositioner). Indicator -> Icon. viewport boolean prop
removed; align forwarded to Positioner. New data-instant,
data-activation-direction hooks; vars -> --positioner-height/width,
--popup-height/width.
Slot.Root + asChild -> useRender + mergeProps
(useRender.ComponentProps<"a">, render prop, state.slot).
anchor prop; our wrapper simply dropped the part).onOpenChange(open) vs Base UI
onOpenChange(open, event, reason) style differences; wrappers pass
through so the pair diff cannot see them. Check per primitive.open, value, highlighted)
and any defaultChecked/checked nuances.onInteractOutside, onEscapeKeyDown ->
Base UI equivalents) which our wrappers do not surface.Radix:
import { Slot } from "radix-ui"
function BreadcrumbLink({ asChild, className, ...props }: React.ComponentProps<"a"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "a"
return <Comp data-slot="breadcrumb-link" className={cn("...", className)} {...props} />
}Base UI:
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
render,
props: mergeProps<"a">(
// PITFALL: data-* attributes fail excess-property checking when passed
// as an object literal into mergeProps (they are only special-cased in
// JSX). Cast the literal:
{ "data-slot": "breadcrumb-link", className: cn("...", className) } as React.ComponentProps<"a">,
props
),
})
}Two rules:
button.tsx migrates to the real
@base-ui/react/button primitive, which accepts render natively.data-* keys passed to
mergeProps (as React.ComponentProps<"tag">), or tsc fails on every one.When a wrapper exposes positioning props via
Pick<XPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">,
you MUST destructure each of those props in the wrapper and pass them to
<XPrimitive.Positioner> explicitly. If you forget, they fall through
...props onto the Popup (wrong DOM node) and positioning silently breaks.
No JSX-level type error catches this; only the wrapper’s own destructuring
discipline and a browser check do. Checklist per overlay wrapper:
declare -> destructure -> forward. All three, every time.