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-baseRadix UI → Base UI props migration mapping (overlays)
Components covered: dialog, alert-dialog, popover, tooltip, hover-card (Base UI: preview-card).
Sources: radix-ui.com primitives docs (fetched 2026-07-02) and base-ui.com /react/components/*.md endpoints (fetched 2026-07-02).
Global conventions that apply to every component below:
asChild(Radix, every part) →render(Base UI, every part). Radix:asChild?: booleanmerges props onto the single child. Base UI:render?: ReactElement | ((props: HTMLProps, state: Part.State) => ReactElement). For buttons replaced with non-button elements, also setnativeButton={false}.onOpenChangesignature changed everywhere. Radix:(open: boolean) => void. Base UI:(open: boolean, eventDetails: X.Root.ChangeEventDetails) => voidwhereeventDetailsis{ reason, event, trigger, cancel(), allowPropagation(), isCanceled, isPropagationAllowed, preventUnmountOnClose() }.- Radix per-interaction dismiss callbacks (
onEscapeKeyDown,onPointerDownOutside,onFocusOutside,onInteractOutside) have NO 1:1 Base UI props. They are replaced byonOpenChange'seventDetails.reason('escape-key','outside-press','focus-out') +eventDetails.cancel()to prevent the close (the equivalent of Radixevent.preventDefault()). forceMount(Radix, Portal/Overlay/Content) →keepMountedon Base UIPortalonly (boolean, defaultfalse). For exit animations Base UI does not need it: it holds the popup mounted itself and exposesdata-starting-style/data-ending-style,onOpenChangeComplete, andactionsRef.current.unmount()for externally-controlled animations.- Base UI
classNameandstyleaccept state callbacks ((state) => ...) on every rendered part. - Radix
[data-state="open" | "closed"]→ Base UI presence attributesdata-open/data-closed.
dialog
Part mapping: Root→Root, Trigger→Trigger, Portal→Portal, Overlay→Backdrop, Content→Popup (centered modal: no Positioner), Title→Title, Description→Description, Close→Close.
Root → Root
Trigger → Trigger
Portal → Portal
Overlay → Backdrop
Content → Popup
Title → Title / Description → Description / Close → Close
Base UI only props worth knowing (dialog)
- Root:
actionsRef(RefObject<{ unmount(), close() }>),onOpenChangeComplete: (open: boolean) => void,disablePointerDismissal,modal: 'trap-focus',handle/triggerId/defaultTriggerId+Dialog.createHandle()(detached and multiple triggers),childrenas payload render function. - Trigger:
payload,handle,id,nativeButton. - Popup:
initialFocus,finalFocus. - Backdrop:
forceRender. - New part:
Viewport(scrollable positioning container for the popup, useful for outside-scroll dialogs).
Data attributes (dialog)
CSS variables (dialog)
alert-dialog
Part mapping: Root→Root, Trigger→Trigger, Portal→Portal, Overlay→Backdrop, Content→Popup, Title→Title, Description→Description, Cancel→Close, Action→NO primitive (render a plain button; close via controlled state, Root actionsRef.close(), or reuse AlertDialog.Close with action semantics in the wrapper). Base UI AlertDialog is always modal and never closes on outside press by default (no modal prop, reasons still include 'outside-press'/'focus-out' in the type but pointer dismissal is disabled by design).
Root → Root
(Radix AlertDialog.Root has no modal prop; Base UI AlertDialog.Root also has none. Parity.)
Trigger → Trigger
Portal → Portal
Overlay → Backdrop
Content → Popup
(Radix AlertDialog.Content intentionally has no onPointerDownOutside/onInteractOutside; nothing to map.)
Title → Title / Description → Description
Same as dialog: asChild → render. Title renders <h2>, Description renders <p>.
Cancel → Close
Action → (no primitive)
Base UI only props worth knowing (alert-dialog)
- Root:
actionsRef,onOpenChangeComplete,handle/triggerId/defaultTriggerId+AlertDialog.createHandle(), payload-renderchildren. - Popup:
initialFocus,finalFocus. - Backdrop:
forceRender. New part:Viewport.
Data attributes (alert-dialog)
Identical table to dialog: data-state="open"/"closed" → data-open/data-closed (Backdrop, Popup, Viewport); Trigger data-state → data-popup-open; Base-only data-disabled (Trigger, Close), data-starting-style, data-ending-style, data-nested, data-nested-dialog-open.
CSS variables (alert-dialog)
popover
Part mapping: Root→Root, Trigger→Trigger, Anchor→(Positioner anchor prop), Portal→Portal, Content→Portal>Positioner>Popup (positioning props move to Positioner; focus/dismiss concerns split between Popup and Root), Close→Close, Arrow→Arrow. Base UI also has Backdrop, Title, Description, Viewport parts with no Radix counterpart.
Root → Root
Trigger → Trigger
Anchor → Positioner anchor prop
Portal → Portal
Content → Positioner + Popup
Close → Close
Arrow → Arrow
Base UI only props worth knowing (popover)
- Root:
actionsRef,onOpenChangeComplete,handle/triggerId/defaultTriggerId+Popover.createHandle(), payload-renderchildren,modal: 'trap-focus'. - Trigger:
openOnHover+delay+closeDelay(hover-open popovers),payload,nativeButton,id. - Positioner:
positionMethod('absolute' \| 'fixed'),disableAnchorTracking,anchor,collisionAvoidance. - Popup:
initialFocus,finalFocus. - New parts:
Backdrop,Title,Description,Viewport(animated content swaps between multiple triggers).
Data attributes (popover)
CSS variables (popover)
tooltip
Part mapping: Provider→Provider, Root→Root, Trigger→Trigger, Portal→Portal, Content→Portal>Positioner>Popup, Arrow→Arrow. Delay control moves: Radix delayDuration lives on Provider/Root; Base UI open/close delays live on Provider (delay/closeDelay) and Trigger (delay/closeDelay).
Provider → Provider
Root → Root
Trigger → Trigger
Portal → Portal
Content → Positioner + Popup
Arrow → Arrow
Base UI only props worth knowing (tooltip)
- Root:
trackCursorAxis('none' \| 'x' \| 'y' \| 'both', default'none'),disabled,disableHoverablePopup,actionsRef,onOpenChangeComplete,handle/triggerId/defaultTriggerId+Tooltip.createHandle(), payload-renderchildren. - Provider:
closeDelay(shared close delay, no Radix counterpart). - Trigger:
closeOnClick,disabled,delay,closeDelay,payload. - Positioner:
positionMethod,disableAnchorTracking,anchor,collisionAvoidance. - New part:
Viewport.
Data attributes (tooltip)
CSS variables (tooltip)
hover-card → preview-card
Part mapping: HoverCard.Root→PreviewCard.Root, Trigger→Trigger, Portal→Portal, Content→Portal>Positioner>Popup, Arrow→Arrow. Delays move from Root to Trigger. Both libraries render the trigger as an <a> element.
Root → Root
Trigger → Trigger
Portal → Portal
Content → Positioner + Popup
(Radix HoverCard.Content documents no dismiss callbacks; escape/outside dismissal maps to Root onOpenChange reasons 'escape-key' / 'outside-press' if needed.)
Arrow → Arrow
Base UI only props worth knowing (preview-card)
- Root:
actionsRef,onOpenChangeComplete,handle/triggerId/defaultTriggerId+PreviewCard.createHandle(), payload-renderchildren. - Trigger:
payload,handle, per-triggerdelay/closeDelay. - Positioner:
positionMethod,disableAnchorTracking,anchor,collisionAvoidance. - New parts:
Backdrop,Viewport.