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 mapping: progress, scroll-area, separator, avatar, toast, form
Sources: radix-ui/website data/primitives/docs/components/*.mdx (full inline prop tables) and base-ui.com /react/components/{progress,scroll-area,separator,avatar,toast,form,field,fieldset}.md (fetched 2026-07-02, @base-ui/react, formerly @base-ui-components/react).
Universal conventions (apply to every part below, not repeated per table):
asChild(boolean) →render(ReactElement | ((props: HTMLProps, state) => ReactElement)). Signature changed:<Part asChild><a/></Part>→<Part render={<a/>} />.- Base UI
classNameandstylealso accept a function of the part'sStateobject. - Every Base part exposes
Part.PropsandPart.Statetypes (e.g.Progress.Root.Props).
progress
Part mapping: Progress.Root → Progress.Root, Progress.Indicator → Progress.Indicator (now MUST be nested in the new Progress.Track). Base UI adds Track, Label, Value parts. The primitive computes the Indicator fill width itself (inline style), so the Radix pattern style={{ transform: translateX(-(100 - value)%) }} on Indicator is deleted, not ported.
Progress.Root → Progress.Root
Progress.Indicator → Progress.Indicator
Base UI only props worth knowing
- Root:
min(0),format(Intl.NumberFormatOptions),locale(Intl.LocalesArgument),aria-valuetext. - New parts:
Progress.Track(contains Indicator),Progress.Label(accessible label,<span>),Progress.Value(formatted value text,<span>,childrenrender fn(formattedValue, value) => ReactNode).
Data attributes
All Base attrs are present on Root, Track, Indicator, Label, and Value alike. State type: { status: 'indeterminate' | 'progressing' | 'complete' }.
CSS variables
None on either side.
scroll-area
Part mapping: ScrollArea.Root → ScrollArea.Root, ScrollArea.Viewport → ScrollArea.Viewport, ScrollAreaScrollbar → ScrollArea.Scrollbar, ScrollAreaThumb → ScrollArea.Thumb, ScrollArea.Corner → ScrollArea.Corner. Base UI adds ScrollArea.Content (wraps content inside Viewport, needed for horizontal overflow measurement).
ScrollArea.Root → ScrollArea.Root
ScrollArea.Viewport → ScrollArea.Viewport
ScrollAreaScrollbar → ScrollArea.Scrollbar
ScrollAreaThumb → ScrollArea.Thumb
ScrollArea.Corner → ScrollArea.Corner
Base UI only props worth knowing
- Root:
overflowEdgeThreshold(number | Partial<{ xStart; xEnd; yStart; yEnd }>, default0), threshold before the overflow edge attributes flip. - New part:
ScrollArea.Content(div inside Viewport; same overflow data attributes as Root).
Data attributes
CSS variables
Radix's scroll-area docs list no CSS variables (its implementation ships undocumented --radix-scroll-area-thumb-*/corner-* vars). Base UI documents:
separator
Part mapping: Separator.Root → Separator (callable single part, no .Root).
Separator.Root → Separator
Base UI only props worth knowing
None beyond the universal className/style/render. Renders a <div>.
Data attributes
[data-orientation] with values horizontal | vertical: identical on both sides.
CSS variables
None on either side.
avatar
Part mapping: Avatar.Root → Avatar.Root, Avatar.Image → Avatar.Image, Avatar.Fallback → Avatar.Fallback. Same anatomy. Base Root renders <span>, Image <img>, Fallback <span>.
Avatar.Root → Avatar.Root
Avatar.Image → Avatar.Image
Avatar.Fallback → Avatar.Fallback
Base UI only props worth knowing
Nothing beyond the universal trio. Part State exposes imageLoadingStatus (and transitionStatus on Image) for className/style functions.
Data attributes
Radix documents none. Base UI Image adds data-starting-style / data-ending-style for enter/exit transitions.
CSS variables
None on either side.
toast
The mental model changes completely: Radix toast is declarative (you render <Toast.Root open> yourself), Base UI toast is manager-driven. Toasts are created imperatively via Toast.useToastManager().add({ title, description, ... }) (or a global Toast.createToastManager() passed to Provider toastManager), and you render useToastManager().toasts.map((toast) => <Toast.Root key={toast.id} toast={toast} />) inside the Viewport.
Part mapping:
Toast.Provider → Toast.Provider
Toast.Viewport → Toast.Portal + Toast.Viewport
Base UI only: Portal.container (HTMLElement | ShadowRoot | ref | null).
Toast.Root → Toast.Root
Base UI only on Root: toast (required Toast.Root.ToastObject: id, title, description, type, timeout, priority, updateKey, limited, height, onClose, onRemove, actionProps, positionerProps, data), swipeDirection (single or array).
Toast.Title → Toast.Title
Toast.Description → Toast.Description
Toast.Action → Toast.Action
Base UI only: nativeButton (boolean, default true, set false when render is not a button).
Toast.Close → Toast.Close
Base UI only: nativeButton (as on Action).
Base UI only props worth knowing
- Provider:
limit(number, default3; overflowing toasts getdata-limited+inertinstead of being removed),toastManager(fromToast.createToastManager()for use outside React). - Manager API (
useToastManager()return /createToastManager()):toasts,add(options) => id,close(id?),update(id, options),promise(promise, { loading, success, error }). - New parts:
Toast.Content(clips overflow while the stack is collapsed;data-behind,data-expanded),Toast.Positioner/Toast.Arrowfor anchored toasts (full popup positioning surface:anchor,sidedefault'top',align,sideOffset,alignOffset,collisionAvoidance,collisionBoundary,collisionPadding,arrowPadding,sticky,positionMethod,disableAnchorTracking).
Data attributes
CSS variables
form
Base UI splits Radix Form across three components: Form (@base-ui/react/form, a callable single part rendering <form>), Field (@base-ui/react/field: Root, Label, Control, Error, Description, Validity, Item), and Fieldset (@base-ui/react/fieldset: Root, Legend).
Part mapping:
Form.Root → Form
Base UI only on Form: errors (Errors), onFormSubmit ((formValues, eventDetails) => void), validationMode ('onSubmit' | 'onBlur' | 'onChange', default 'onSubmit'), actionsRef ({ validate(fieldName?) }).
Form.Field → Field.Root
Base UI only on Field.Root: validate ((value, formValues) => string | string[] | Promise<...> | null, the custom-validation replacement for Radix function match), validationMode, validationDebounceTime (0), disabled, invalid, dirty, touched, actionsRef ({ validate() }).
Form.Label → Field.Label
Base UI only: nativeLabel (boolean, default true; set false when render swaps in a non-label element, e.g. a <div> labelling a <Select.Trigger> button).
Form.Control → Field.Control
Base UI only: defaultValue (string | number | string[]), onValueChange ((value, eventDetails) => void).
Form.Message → Field.Error
Note: Radix rendered default English messages per match when children were omitted; Base UI renders the error string coming from validate/Form errors, otherwise provide children. Field.Description (className/style/render only) is the new home for non-error helper text. Error renders a <div>, Description a <p>.
Form.ValidityState → Field.Validity
Form.Submit → (none)
(new) Fieldset.Root and Fieldset.Legend
No Radix counterpart. Fieldset.Root renders a native <fieldset> (props: className/style/render; state { disabled }, data-disabled). Fieldset.Legend renders a <div> automatically associated as the accessible legend. Use to group related fields under one label.
Base UI only props worth knowing (form-wide)
- Validation timing is configurable (
validationModeon Form or per Field,validationDebounceTime). actionsRefimperativevalidate()on both Form and Field.Root.Field.Itemgroups a single checkbox/radio inside a group with its own label/description (disabledprop).- Focus is moved to the first invalid field on submit, matching Radix behavior.
Data attributes
CSS variables
None on either side.
No Base UI counterpart
Radix utilities with no Base UI equivalent, and the recommended plain replacements:
Label (radix Label.Root: asChild, htmlFor)
Use a native <label htmlFor="...">, or Field.Label when inside a Field.Root (which auto-wires the association, no htmlFor needed). Radix's only behavioral extra (preventing text selection on double click) is one line of CSS: select-none / user-select: none.
AspectRatio (radix AspectRatio.Root: asChild, ratio default 1)
Use the CSS aspect-ratio property, which is what the prop mapped to: ratio={16 / 9} → aspect-video or aspect-[16/9] (aspect-ratio: 16 / 9), plus w-full and object-cover on the media child.
VisuallyHidden (radix VisuallyHidden.Root: asChild)
Use Tailwind's sr-only class on a <span> (the standard clip-rect pattern). Note: some Base UI popup components in other files still need hidden titles for a11y; <span className="sr-only"> covers that too.
AccessibleIcon (radix AccessibleIcon.Root: label required)
It was only VisuallyHidden + aria-hidden composed: render the icon with aria-hidden="true" (or focusable="false") and add <span className="sr-only">{label}</span> next to it, or put aria-label={label} on the interactive parent (button/link) instead.