Deft
Ask AI

Search

Search skills, authors and topics

migrate-radix-to-base

Migrate React projects and shadcn components from Radix UI to Base UI, handling route objects, consumer props, and progressive wrapper migration.

Install

npx skills add https://github.com/shadcn-ui/ui --skill migrate-radix-to-base

Radix -> Base UI migration mapping

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 primitives/ skills.

Coverage matrix

All radix-ui exports, classified for migration:

Radix primitiveBase UI targetClass
AccordionAccordiondirect (Content->Panel)
AlertDialogAlert Dialogrestructured (Overlay->Backdrop, Content->Popup, Cancel->Close, Action dropped)
AspectRationonemissing: plain div + CSS aspect-ratio (--ratio var)
AvatarAvatardirect
CheckboxCheckboxdirect (cleanest 1:1)
CollapsibleCollapsibledirect (Content->Panel)
ContextMenuContext Menurestructured (menu mapping)
DialogDialogrestructured (Overlay->Backdrop, Content->Popup)
DropdownMenuMenuRENAMED + restructured (canonical menu mapping)
FormForm + Field + Fieldsetrestructured (split into three)
HoverCardPreview CardRENAMED + positioner model
Labelnonemissing: native <label> (Field.Label inside forms)
MenubarMenubar + Menurestructured (menubar root only; menus delegate to Menu)
NavigationMenuNavigation Menuheavily restructured (Viewport -> Positioner/Popup/Viewport, Indicator->Icon)
PopoverPopoverpositioner model (Anchor dropped; verify vs docs)
ProgressProgressrestructured (new Track/Label/Value parts, no manual transform)
RadioGroupRadio Group + Radiorestructured (Item -> Radio.Root, two subpath imports)
ScrollAreaScroll Areadirect (Scrollbar/Thumb renames)
SelectSelectrestructured (Viewport->List, ScrollButtons->ScrollArrows, alignItemWithTrigger)
SeparatorSeparatordirect (callable; decorative dropped)
SliderSliderrestructured (Range->Indicator, new Control, thumbAlignment)
SwitchSwitchdirect (1:1)
TabsTabsdirect (Trigger->Tab, Content->Panel)
ToastToastrestructured (not in our registry pairs; spec from docs; shadcn users mostly use sonner)
ToggleToggledirect (callable)
ToggleGroupToggle Group + Toggledirect (items use Toggle primitive)
ToolbarToolbardirect-ish (not in our pairs; spec from docs)
TooltipTooltippositioner model (delayDuration->delay on Provider)
unstable_OneTimePasswordFieldOTP Fieldfrom docs (our registry uses input-otp instead)
unstable_PasswordToggleFieldnonemissing: Input + custom toggle

Utilities:

Radix utilityBase UI equivalent
Slot / asChildrender prop; useRender + mergeProps for the manual Slot idiom
Portalnone standalone; per-component Portal parts
VisuallyHiddennone; sr-only class
AccessibleIconnone; aria-label + sr-only text
DirectionDirection 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).

Universal patterns (apply across all components)

Imports

Radix appears in TWO import forms; both map to the same Base UI subpath:

  • Unified package (current shadcn): import { X as XPrimitive } from "radix-ui" -> import { X as XPrimitive } from "@base-ui/react/<kebab-name>".
  • Individual packages (legacy/2024-era, e.g. fixture 03): 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.
  • Types: React.ComponentProps<typeof XPrimitive.Part> -> XPrimitive.Part.Props. Positioner props via Pick<XPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">.
  • Single-part primitives are callable: radix XPrimitive.Root -> XPrimitive (separator, toggle, toggle-group root, radio-group root, menubar root).

asChild -> render

  • <Primitive.Close asChild><Button/></Primitive.Close> -> <Primitive.Close render={<Button/>}>...</Primitive.Close>.
  • Manual Slot idiom (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 / positioning model (biggest structural change)

  • Radix: Portal > Content, positioning props on Content.
  • Base UI: 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 attributes / class hooks

  • data-[state=open] -> data-open; data-[state=closed] -> data-closed.
  • Enter/exit animations: data-[state=open]:animate-in / data-[state=closed]:animate-out -> data-starting-style:* / data-ending-style:* (transition-based, not keyframes).
  • New Base UI hook: data-popup-open (open-submenu/trigger marker).
  • Some triggers gain aria-disabled:* variants alongside disabled:* (accordion, tabs).

CSS custom properties

  • --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
  • nav-menu --radix-navigation-menu-viewport-height/width -> --positioner-height/width, --popup-height/width, --available-width

Props

  • Tooltip Provider: delayDuration -> delay.
  • Select: position="popper"|"item-aligned" -> alignItemWithTrigger boolean.
  • Slider: gains thumbAlignment ("edge"); Range -> Indicator + new Control.
  • Navigation Menu: 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).

Part-rename quick reference

radix partBase UI part
*.Root (single-part comps)callable *Primitive
OverlayBackdrop
Content (overlay comps)Popup (inside Positioner)
Content (accordion/collapsible/tabs)Panel
tabs TriggerTab
menu LabelGroupLabel
menu ItemIndicatorCheckboxItemIndicator / RadioItemIndicator
Sub / SubTriggerSubmenuRoot / SubmenuTrigger
slider RangeIndicator (+ new Control)
select ViewportList
select ScrollUp/DownButtonScrollUp/DownArrow
scroll-area ScrollAreaScrollbar / ScrollAreaThumbScrollbar / Thumb
nav-menu IndicatorIcon
nav-menu ViewportPositioner > Popup > Viewport
hover-card HoverCard*PreviewCard*
radio-group Item / IndicatorRadio.Root / Radio.Indicator
popover Anchordropped (verify against docs)
alert-dialog Cancel / ActionClose / dropped (plain Button)
separator decorative propdropped
Label primitivenative <label>

Per-component notes

accordion

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.

dialog / alert-dialog / sheet

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.

drawer (vaul -> Base UI) — OPT-IN ONLY, not part of a radix migration

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.

popover / tooltip / hover-card

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).

select

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.

form controls

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 / collapsible / progress / separator / scroll-area / label

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).

Doc-validation TODOs (before specs are final)

  1. Popover Anchor: confirm Base UI has no anchor equivalent (Positioner may accept an anchor prop; our wrapper simply dropped the part).
  2. Callback signatures: radix onOpenChange(open) vs Base UI onOpenChange(open, event, reason) style differences; wrappers pass through so the pair diff cannot see them. Check per primitive.
  3. Toast, Toolbar, Form/Field/Fieldset, OTP Field: not covered by our pairs; author these specs from docs alone.
  4. Controlled-prop names on menus/select (open, value, highlighted) and any defaultChecked/checked nuances.
  5. Focus/dismissal behavior knobs (onInteractOutside, onEscapeKeyDown -> Base UI equivalents) which our wrappers do not surface.

Slot -> useRender: WORKED EXAMPLE (avoid the mergeProps pitfall)

Radix:

tsx
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:

tsx
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:

  1. This pattern is ONLY for non-button polymorphic components (breadcrumb link, marker, badge, item...). button.tsx migrates to the real @base-ui/react/button primitive, which accepts render natively.
  2. Always cast object literals containing data-* keys passed to mergeProps (as React.ComponentProps<"tag">), or tsc fails on every one.

Positioner props: Pick means FORWARD

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.