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 to Base UI props mapping: form controls
Scope: select, checkbox, radio-group, switch, slider.
Sources: radix-ui.com primitives docs and base-ui.com /react/components/*.md (fetched 2026-07-02).
Global conventions that apply to every part below:
asChild(Radix,boolean, defaultfalse) ->render(Base UI,ReactElement | ((props, state) => ReactElement)). Every Base UI part acceptsrender, plusclassName/styleas either plain values or state callbacks ((state) => ...).- Base UI parts that render interactive elements accept
nativeButton(tells Base UI whether therendertarget is a native<button>), which has no Radix equivalent. - Callbacks fire with a second
eventDetailsargument ({ reason, event, cancel(), allowPropagation(), isCanceled, isPropagationAllowed, trigger }).eventDetails.cancel()replaces Radix'sevent.preventDefault()pattern for preventing default component behavior. - Radix
data-state="x"tokens become presence attributes in Base UI (data-checked,data-unchecked,data-open, ...). Base UI adds Field-integration attributes everywhere (data-valid,data-invalid,data-dirty,data-touched,data-filled,data-focused) and animation attributes (data-starting-style,data-ending-style). - Radix
dirprops have no Base UI per-component equivalent, direction comes fromDirectionProvider(or thedirHTML attribute).
select
Part mapping: Root -> Root, Trigger -> Trigger, Value -> Value, Icon -> Icon, Portal -> Portal, Content -> Portal > Positioner > Popup (split into three parts), Viewport -> List, Item -> Item, ItemText -> ItemText, ItemIndicator -> ItemIndicator, ScrollUpButton -> ScrollUpArrow, ScrollDownButton -> ScrollDownArrow, Group -> Group, Label -> GroupLabel (Base UI's Select.Label is a NEW part that labels the trigger, not groups), Separator -> Separator, Arrow -> Arrow.
Select.Root → Select.Root
Base UI Select.Root renders no HTML element (Radix Root doesn't either).
Select.Trigger → Select.Trigger
Select.Value → Select.Value
Select.Icon → Select.Icon
Select.Portal → Select.Portal
Select.Content → Select.Portal > Select.Positioner > Select.Popup (moved/split)
Radix Content handled positioning, collision, and the panel in one part. In Base UI, positioning props live on Positioner, panel/focus props live on Popup, dismiss interception lives on Root.onOpenChange eventDetails.
Select.Viewport → Select.List (renamed)
Select.Item → Select.Item
Select.ItemText → Select.ItemText
Select.ItemIndicator → Select.ItemIndicator
Select.ScrollUpButton / ScrollDownButton → Select.ScrollUpArrow / ScrollDownArrow (renamed)
Select.Group → Select.Group
Select.Label → Select.GroupLabel (renamed)
Select.Separator → Select.Separator
Select.Arrow → Select.Arrow
Base UI only props worth knowing (select)
Root.multiple: boolean(defaultfalse): multi-select withValue[]values, no Radix equivalent.Root.items:Record<string, ReactNode> | { label, value }[] | Group[], letsSelect.Valuerender labels instead of raw values.Root.isItemEqualToValue,Root.itemToStringLabel,Root.itemToStringValue: object-value support.Root.modal: boolean(defaulttrue): scroll lock + outside pointer blocking; Radix select was always modal-ish, setmodal={false}for non-modal behavior.Root.readOnly,Root.autoComplete,Root.form,Root.inputRef,Root.id,Root.onOpenChangeComplete,Root.actionsRef({ unmount() }for externally controlled exit animations),Root.highlightItemOnHover(defaulttrue).- New parts:
Select.Backdrop(overlay under the popup),Select.Label(trigger label),Popup.finalFocus. Positioner.anchor,Positioner.positionMethod('absolute' | 'fixed'),Positioner.disableAnchorTracking.
Data-attribute mapping (select)
CSS variable mapping (select)
All Base UI vars are set on Select.Positioner (Radix set them on Content, popper mode only):
checkbox
Part mapping: Root -> Root, Indicator -> Indicator. Element change: Radix Root renders a <button> plus hidden input inside a form; Base UI Root renders a <span> plus hidden <input> always (use nativeButton + render to render a real button).
Checkbox.Root → Checkbox.Root
Checkbox.Indicator → Checkbox.Indicator
Base UI only props worth knowing (checkbox)
Root.indeterminate: boolean(defaultfalse): the mixed state, decoupled fromchecked.Root.parent: boolean+CheckboxGroup(new component,base-ui.com/react/components/checkbox-group):<CheckboxGroup value/defaultValue/onValueChange(string[], eventDetails)/allValues/disabled>provides shared state for a set of checkboxes and enables a parent "select all" checkbox. No Radix equivalent, new capability.Root.readOnly: boolean,Root.uncheckedValue: string(value submitted when unchecked),Root.form: string,Root.inputRef,Root.id,Root.nativeButton.
Data-attribute mapping (checkbox)
No CSS variables on either side.
radio-group
Part mapping: Radix ships one RadioGroup namespace; Base UI splits it into RadioGroup (a single component, no sub-parts) and Radio (Radio.Root, Radio.Indicator). RadioGroup.Root -> RadioGroup, RadioGroup.Item -> Radio.Root, RadioGroup.Indicator -> Radio.Indicator. Element change: Radix Item renders <button>; Base UI Radio.Root renders <span> plus hidden <input> (use nativeButton + render for a real button).
RadioGroup.Root → RadioGroup
RadioGroup.Item → Radio.Root (moved to Radio namespace)
RadioGroup.Indicator → Radio.Indicator (moved to Radio namespace)
Base UI only props worth knowing (radio-group)
RadioGroup.readOnly,RadioGroup.form,RadioGroup.inputRef(the group owns one hidden input).Radio.Root.readOnly,Radio.Root.inputRef,Radio.Root.nativeButton.
Data-attribute mapping (radio-group)
No CSS variables on either side.
switch
Part mapping: Root -> Root, Thumb -> Thumb. Element change: Radix Root renders <button> + hidden input in forms; Base UI Root renders <span> plus hidden <input> always (use nativeButton + render for a real button).
Switch.Root → Switch.Root
Switch.Thumb → Switch.Thumb
Base UI only props worth knowing (switch)
Root.readOnly: boolean,Root.uncheckedValue: string,Root.form: string,Root.inputRef,Root.id,Root.nativeButton(defaultfalse).
Data-attribute mapping (switch)
No CSS variables on either side.
slider
Part mapping: Root -> Root, Track -> Track, Range -> Indicator (renamed), Thumb -> Thumb, plus a NEW required Control part: Base UI anatomy is Root > Control > Track > (Indicator, Thumb). Control is the clickable/draggable surface (Radix Root handled pointer interaction itself). Base UI also adds Value and Label parts. Element change: Radix Thumb renders a plain element wrapped by an invisible span with a hidden input in forms; Base UI Thumb renders a <div> with a nested <input type="range">.
Slider.Root → Slider.Root
Slider.Track → Slider.Track (moved inside Control)
Slider.Range → Slider.Indicator (renamed)
Slider.Thumb → Slider.Thumb
(new part) Slider.Control
No Radix equivalent. The interactive surface that receives pointer events; wrap Track with it. Props: className/style/render only.
Base UI only props worth knowing (slider)
Root.thumbAlignment: 'center' | 'edge' | 'edge-client-only'(default'center'): whether the thumb center or edge aligns with the control edge at min/max. Radix always behaved like'edge'-ish via CSS; Base UI defaults to'center', setthumbAlignment="edge"to keep the thumb inside the track bounds.Root.thumbCollisionBehavior: 'push' | 'swap' | 'none'(default'push'): range-slider thumb collision handling (Radix behavior was closest to'none').Root.largeStep: number(default10): Page Up/Down and Shift+Arrow increment.Root.format: Intl.NumberFormatOptionsandRoot.locale: Intl.LocalesArgument: value formatting forSlider.Valueandaria-valuetext.- New parts:
Slider.Value(renders<output>,children: (formattedValues: string[], values: number[]) => ReactNode),Slider.Label(auto-associated label).
Data-attribute mapping (slider)
No CSS variables on either side (Radix slider positions thumbs via inline styles; Base UI does the same).