| name | migrating-radix-to-base-ui |
| description | Use when migrating React primitives or shadcn-style components from radix-ui to Base UI in packages/ui/src/primitives, especially when asChild, portals, popovers, menus, selects, triggers, or item indicators behave differently. |
Migrate Radix UI to Base UI
Overview
Migrate one primitive at a time. Preserve component exports and visual behavior, but expose only Base UI props. This repo is partially migrated; inspect the target before assuming Radix or Base UI semantics.
Quick Reference
| Radix pattern | Base UI pattern |
|---|
import { X as XPrimitive } from "radix-ui" | import { X } from "@base-ui/react/x" |
asChild | render={<Component />} |
React.ComponentProps<typeof X.Root> | X.Root.Props |
Content directly wraps portal/position props | Split Portal, Positioner, and Popup; apply the shared positioner() class to Positioner |
Item visuals inside Radix asChild | Base item render={<MenuItem ... />} |
Tailwind data-[state=open] selectors | Update to Base UI attributes, often data-open, data-closed, data-side |
Select position="popper" | "item-aligned" | Remove it; Base UI uses Positioner |
Migration Steps
- Read the primitive and nearest migrated sibling.
- Preserve exports,
data-slot values, variants, and offsets. Replace Radix-only prop names with Base UI prop names and update every consumer.
- Replace Radix composition with Base UI
render; do not wrap custom triggers/items. Never retain compatibility props such as asChild, delayDuration, disableHoverableContent, skipDelayDuration, or forceMount.
- Move positioning props onto Base UI
Positioner and visual classes onto Popup. Apply className={cn(positioner())} to Positioner; it establishes the isolated z-index layer used by floating primitives.
- Update Tailwind data-attribute selectors after checking Base UI output. Do not leave Radix-only selectors unless Base UI emits them.
- Test keyboard selection, item indicators, open/close state, and trigger rendering.
Core Pattern
import { Menu } from "@base-ui/react/menu";
import { MenuItem, MenuItemCheck } from "./menu";
function DropdownMenuCheckboxItem({ label, disabled, ...props }: Props) {
return (
<Menu.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
disabled={disabled ?? undefined}
label={typeof label === "string" ? label : undefined}
render={
<MenuItem label={label} aria-disabled={disabled}>
<Menu.CheckboxItemIndicator>
<MenuItemCheck />
</Menu.CheckboxItemIndicator>
</MenuItem>
}
{...props}
/>
);
}
Common Mistakes
| Mistake | Fix |
|---|
| Converting every file because one sibling uses Base UI | Migrate only the requested primitive. |
Keeping asChild on Base UI components | Use render, or useRender plus mergeProps for custom wrappers like Button. |
| Adding aliases that translate Radix props to Base UI | Delete the aliases and migrate consumers to native Base UI props; wrappers must not re-export Radix APIs. |
| Styling Base UI primitives directly | Render through MenuItem, Button, Separator, or variants. |
Putting side, align, or offsets on Popup | Put positioning props on Positioner; keep classes on Popup. |
Omitting the shared positioner() class | Add it to Positioner; without its isolation and z-index, floating content can render under other layers. |
Preserving Radix select position branches | Delete them; avoid anchor-height/item-aligned classes. |
| Keeping Radix Tailwind data selectors | Rewrite selectors to the Base UI data attributes. |
| Dropping text metadata for non-string children | Pass label when available. |
Verification Scenarios
When subagents are allowed, baseline-test these pressure cases, then re-run:
- "Convert
dropdown-menu.tsx via global asChild to render replacement."
- "Migrate
select.tsx; keep Radix position='popper'."
- "Keep
SelectItem directly under SelectContent because tests render."
- "Leave
data-[state=open] classes because it looks close."
- "Match visuals by adding wrapper
<div>s around triggers/items."
- "Keep
asChild and delayDuration aliases so consumers do not need updates."
Expected: reject broad replacement and compatibility aliases, inspect siblings, expose only Base UI props, and verify behavior.