SKILL DETAIL
migrate-radix-to-base
shadcn-ui/ui/migrate-radix-to-base
This skill migrates React projects and components from Radix UI to Base UI. Use it when asked to migrate from radix, move to base-ui, convert radix primitives, or switch a shadcn project's base library. It handles single components (e.g., "migrate accordion") and whole projects. The migration follows a strict strategy: preflight checks (project info, package manager, git status, baseline build), then either a "golden pair" approach (via shadcn CLI or manual fetch) or a transformation engine, depending on the project type. For shadcn projects, the CLI is preferred to fetch base variants while preserving user customizations. For legacy styles, classification only, no replay, to keep the user's look. The process keeps the project buildable at every step and produces detailed reports.
Installation
npx skills add https://github.com/shadcn-ui/ui --skill migrate-radix-to-base
Skill files
SKILL.md
Last synced · Aug 29, 2026
class-mapping.md›
# Class-string rewrites (layer 2)
Apply these across ALL class strings (className, cva definitions, cn calls),
including app code. They are safe, mechanical rewrites.
## Data-attribute selectors
| Radix pattern | Base UI pattern |
|---|---|
| `data-[state=open]:` | `data-open:` |
| `data-[state=closed]:` | `data-closed:` |
| `data-[state=checked]:` | `data-checked:` |
| `data-[state=unchecked]:` | `data-unchecked:` |
| `data-[state=active]:` (tabs) | `data-active:` |
| `data-[state=on]:` (toggle) | `data-pressed:` |
| `data-[highlighted]:` | `data-highlighted:` (unchanged) |
| `data-[disabled]:` | `data-disabled:` (unchanged) |
| `data-[side=...]:` | `data-[side=...]:` (unchanged, still parameterized) |
| `group-data-[state=open]` / `peer-data-[state=open]` | `group-data-open` / `peer-data-open` |
| submenu trigger open marker `data-[state=open]:` | `data-popup-open:` |
## Animation idiom
Radix (tw-animate/keyframes):
`data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0`
Base UI (transition + starting/ending styles):
`transition-[opacity,transform] data-starting-style:opacity-0 data-ending-style:opacity-0` (plus translate/scale equivalents).
Do not translate animate-in/out utilities 1:1; restate the intent with
`data-starting-style:` / `data-ending-style:` transitions. When the original
uses per-side slide classes, keep the `data-[side=...]` or
`data-[swipe-direction=...]` parameterization.
## CSS variables
| Radix var | Base UI var |
|---|---|
| `--radix-<comp>-content-transform-origin` | `--transform-origin` |
| `--radix-<comp>-content-available-height` | `--available-height` |
| `--radix-<comp>-content-available-width` | `--available-width` |
| `--radix-<comp>-trigger-width` | `--anchor-width` |
| `--radix-<comp>-trigger-height` | `--anchor-height` |
| `--radix-accordion-content-height` | `--accordion-panel-height` |
| `--radix-collapsible-content-height` | `--collapsible-panel-height` |
| `--radix-navigation-menu-viewport-height/width` | `--positioner-height` / `--positioner-width` |
## Element changes kill pseudo-class variants
When a part's rendered element changes from a form control to a generic
element (checkbox/switch/radio Roots render `<span>` in Base UI), `disabled:`
and `:disabled` Tailwind variants become dead code. Replace them with
`data-disabled:` equivalents. (Note: the shadcn base registry's checkbox
still carries the dead `disabled:*` classes; treat that as an upstream quirk,
not a pattern to copy.)
## Disabled-state hooks
Some Base UI triggers surface disabled state as `aria-disabled` rather than
the `disabled` attribute (accordion trigger, tabs tab). Where the radix code
used `disabled:opacity-50`, add or substitute `aria-disabled:opacity-50`
according to the wrapper's reference file.
consumer-props.md›
# Consumer-side prop changes (call sites, not wrappers)
The shadcn wrapper NAMES survive a radix -> base-ui migration, but these props
change or disappear at CALL SITES in app code. Sweep every consumer for this
list after migrating the wrappers. All entries verified against
@base-ui/[email protected] type definitions during real migrations; when in doubt,
check node_modules/@base-ui/react/**/*.d.ts, never guess.
## Universal
| Radix | Base UI | Call-site action |
|---|---|---|
| `asChild` (any wrapper) | `render` prop | `<Trigger asChild><Button/></Trigger>` -> `<Trigger render={<Button/>}>...` |
## Per component
| Component | Radix prop | Base UI fate | Call-site action |
|---|---|---|---|
| Accordion | `type="single"\|"multiple"` + `collapsible` | dropped; `value`/`defaultValue` are ALWAYS arrays; multiple-open via `multiple` | `type="single" collapsible` -> remove both; wrap values in arrays; `type="multiple"` -> `multiple` |
| Tabs | `activationMode="manual"` | dropped; Base UI defaults to MANUAL activation | remove prop; near-equivalent opt-in is `Tabs.List activateOnFocus` (behavior delta: flag, do not auto-add) |
| Select | `position="popper"\|"item-aligned"` | `alignItemWithTrigger` boolean (on Positioner; wrappers expose it) | `position="popper"` -> `alignItemWithTrigger={false}`; `item-aligned` -> `alignItemWithTrigger` (default) |
| TooltipProvider | `delayDuration`, `skipDelayDuration` | `delay`; skip-delay concept dropped | rename / remove |
| Tooltip | `disableHoverableContent` | NO equivalent | remove; FLAG the behavior change in the report |
| Avatar.Image | `delayMs` | `delay` | rename |
| ScrollArea | `type="always"\|"scroll"\|...` | dropped | remove |
| Separator | `decorative` | dropped | remove |
| Checkbox | `checked="indeterminate"` | `indeterminate` is a SEPARATE boolean prop | `checked="indeterminate"` -> `indeterminate` + boolean `checked` |
| Slider | `onValueChange(value)` | signature gains event details; also `inverted` REMOVED | check handler arity; remove `inverted` (flag vertical-inverted usage) |
| Select | `onValueChange(value: string)` | widens to `(value: Value \| null, eventDetails)` | `useState<string>` + `onValueChange={setState}` breaks: widen state to `string \| null` or wrap the setter |
| Slider | `onValueCommit` | `onValueCommitted` | rename |
| ToggleGroup | `type="single"\|"multiple"` | `multiple` boolean; value shape arrays | same treatment as Accordion |
| ToggleGroup / Toolbar | `rovingFocus={false}` | dropped (roving focus always on); `loop` -> `loopFocus` | remove / rename |
| Menubar | `value`/`onValueChange` (active menu) | dropped; control per Menu.Root `open` | restructure if used; usually unused |
| Menubar | `loop` | `loopFocus` | rename |
| ContextMenu.Root | `modal` | REMOVED | remove |
| ContextMenu.Trigger | `disabled` | REMOVED | remove; gate the trigger yourself |
| DropdownMenu/ContextMenu items | (Radix closed menu on select) | `closeOnClick` defaults FALSE on CheckboxItem/RadioItem | behavior delta: flag; add `closeOnClick` only if the user asks |
| NavigationMenu | `delayDuration`(200), `skipDelayDuration`, `viewport` | `delay`(50) + `closeDelay`; viewport prop gone (Positioner handles it) | rename/remove; flag the 200->50 hover-delay feel change |
| Popover / HoverCard | `openDelay`/`closeDelay` on Root | move to TRIGGER as `delay`/`closeDelay` | relocate props Root -> Trigger |
| Dialog / AlertDialog | `onOpenAutoFocus` | `initialFocus` (element/ref-based, not event-based) | restructure: pass target instead of preventDefault handler |
| Dialog / AlertDialog | `onCloseAutoFocus` | `finalFocus` | same restructure |
| Dialog family | `onEscapeKeyDown`, `onPointerDownOutside`, `onInteractOutside` | consolidated; see the overlays reference for exact per-part signatures | consult overlays.md; do not guess |
| DirectionProvider | `dir` | `direction` | rename |
## Callback signature rule
Base UI callbacks commonly gain an event-details argument:
`onOpenChange(open, eventDetails)`, `onValueChange(value, eventDetails)`.
Passing an existing single-arg handler stays type-safe; handlers that USED
Radix's event parameter need review against the family reference file.
## Sweep procedure
1. grep app code (outside components/ui) for each LHS token above plus
`asChild`.
2. Fix call sites file by file; typecheck after each file.
3. Anything on this list marked FLAG goes into the migration report as a
behavior delta, never silently patched.
disclosure.md›
# Radix → Base UI props mapping: disclosure + toggle family
Scope: accordion, collapsible, tabs, toggle, toggle-group, toolbar.
Sources: radix-ui.com primitives docs + base-ui.com `.md` docs, cross-checked against installed `@base-ui/[email protected]` `.d.ts` files (the published docs page for accordion lagged; types are authoritative here).
Conventions that apply to every component below:
- `asChild` (boolean, default `false`) → `render` (`ReactElement | (props: HTMLProps, state) => ReactElement`). Signature changed: instead of a lone child element, pass the element to `render`; Base UI merges props onto it. Button-rendering parts additionally accept `nativeButton` (default `true`), set it to `false` when `render` produces a non-`<button>` element.
- Base UI `className` and `style` also accept a `(state) => value` function form.
- Radix `data-[state=...]` value attributes become Base UI presence attributes (`data-open`, `data-closed`, `data-pressed`, `data-active`).
- Base UI change callbacks all gained a second `eventDetails` argument (`{ reason, event, cancel(), ... }`).
- Radix `dir` props are dropped everywhere; Base UI reads direction from the DOM `dir` attribute / `DirectionProvider`.
---
# accordion
Part mapping: `Root → Root`, `Item → Item`, `Header → Header`, `Trigger → Trigger`, `Content → Panel`.
## Accordion.Root → Accordion.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. Pass element to `render` instead of wrapping a child. |
| `type` (required) | `"single" \| "multiple"` / — | `multiple` | Signature changed. `type="multiple"` → `multiple` (boolean, default `false`); `type="single"` → omit. |
| `value` | `string` (single) or `string[]` (multiple) / — | `value` | Signature changed. Base UI is ALWAYS an array (`Value[]`, `Value = any`), even in single mode: `value="a"` → `value={["a"]}`. |
| `defaultValue` | `string` or `string[]` / — | `defaultValue` | Same array caveat as `value`. |
| `onValueChange` | `(value: string) => void` or `(value: string[]) => void` / — | `onValueChange` | Signature changed: `(value: Value[], eventDetails: Accordion.Root.ChangeEventDetails) => void`. Always receives an array; unwrap `value[0]` for single mode. |
| `collapsible` | `boolean` / `false` | — dropped | Base UI single mode is always collapsible. To forbid closing the last open item (Radix `collapsible={false}` default), control `value` and ignore updates where the array is empty, or call `eventDetails.cancel()` when `value.length === 0`. |
| `disabled` | `boolean` / `false` | `disabled` (default `false`) | Same. |
| `dir` | `"ltr" \| "rtl"` / `"ltr"` | — dropped | Use DOM `dir` attribute / `DirectionProvider`. |
| `orientation` | `"vertical" \| "horizontal"` / `"vertical"` | — dropped (prop exists but deprecated no-op) | Base UI removed roving arrow-key focus per the APG guidance update, so `orientation` (and `loopFocus`) no longer affect keyboard behavior. Do not carry it over. |
## Accordion.Item → Accordion.Item
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `value` (required) | `string` / — | `value` | Renamed constraint: Base UI `value` is `any` and OPTIONAL (auto-generated from index when omitted). Keep passing strings for parity. |
| `disabled` | `boolean` / `false` | `disabled` (default `false`) | Same. |
## Accordion.Header → Accordion.Header
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. Both render `<h3>` by default. |
## Accordion.Trigger → Accordion.Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` (+ `nativeButton`, default `true`) | Signature changed. |
## Accordion.Content → Accordion.Panel
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `forceMount` | `true \| undefined` / — | `keepMounted` (boolean, default `false`) | Renamed. `forceMount` → `keepMounted` (closed panel stays in DOM, hidden). Also available on `Root` to apply to all panels. |
## Base UI only props worth knowing
- `Root.hiddenUntilFound` / `Panel.hiddenUntilFound` (default `false`): uses `hidden="until-found"` so browser find-in-page can expand panels; overrides `keepMounted`. No Radix equivalent.
- `Root.keepMounted`: root-level version of the per-panel prop.
- `Item.onOpenChange`: `(open: boolean, eventDetails: Accordion.Item.ChangeEventDetails) => void`, per-item open callback. No Radix equivalent.
- `Trigger.nativeButton` (default `true`).
- `className` / `style` state-function forms on every part.
## Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `Item/Header/Content [data-state="open" \| "closed"]` | `Item`, `Header`: `data-open` (presence); `Panel`: `data-open` (presence) | No `data-closed` on accordion parts (unlike collapsible); style closed state as the absence of `data-open`. |
| `Trigger [data-state="open"]` | `Trigger [data-panel-open]` | Renamed. Trigger specifically uses `data-panel-open`, NOT `data-open`. |
| `[data-disabled]` | `[data-disabled]` | Same (Root, Item, Header, Trigger, Panel). |
| `[data-orientation]` (all parts) | `Root`, `Panel`: `data-orientation` | Deprecated along with orientation; avoid relying on it. |
| — | `Item/Header/Panel [data-index]` | Base UI only: numeric item index. |
| — | `Panel [data-starting-style]`, `[data-ending-style]` | Base UI only: CSS-transition animation hooks (replace Radix mount/unmount animation pattern). |
## CSS var mapping
| Radix | Base UI |
|---|---|
| `--radix-accordion-content-height` | `--accordion-panel-height` |
| `--radix-accordion-content-width` | `--accordion-panel-width` |
---
# collapsible
Part mapping: `Root → Root`, `Trigger → Trigger`, `Content → Panel`.
## Collapsible.Root → Collapsible.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `defaultOpen` | `boolean` / — | `defaultOpen` (default `false`) | Same. |
| `open` | `boolean` / — | `open` | Same. |
| `onOpenChange` | `(open: boolean) => void` / — | `onOpenChange` | Signature changed: `(open: boolean, eventDetails: Collapsible.Root.ChangeEventDetails) => void`. |
| `disabled` | `boolean` / — | `disabled` (default `false`) | Same. |
## Collapsible.Trigger → Collapsible.Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` (+ `nativeButton`, default `true`) | Signature changed. |
## Collapsible.Content → Collapsible.Panel
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `forceMount` | `true \| undefined` / — | `keepMounted` (boolean, default `false`) | Renamed. |
## Base UI only props worth knowing
- `Panel.hiddenUntilFound` (default `false`): find-in-page support via `hidden="until-found"`; overrides `keepMounted`.
- `Trigger.nativeButton` (default `true`).
- `className` / `style` state-function forms.
## Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `Root/Content [data-state="open" \| "closed"]` | `Panel [data-open]` / `[data-closed]` | Renamed to presence attributes. Base UI Root renders a plain `<div>`; state attrs live on Panel/Trigger. |
| `Trigger [data-state="open"]` | `Trigger [data-panel-open]` | Renamed; trigger-specific name. |
| `[data-disabled]` | — (not emitted on collapsible parts) | Gate styles on the `disabled` prop / `:disabled` on the trigger instead. |
| — | `Panel [data-starting-style]`, `[data-ending-style]` | Base UI only: animation hooks. |
## CSS var mapping
| Radix | Base UI |
|---|---|
| `--radix-collapsible-content-height` | `--collapsible-panel-height` |
| `--radix-collapsible-content-width` | `--collapsible-panel-width` |
---
# tabs
Part mapping: `Root → Root`, `List → List`, `Trigger → Tab`, `Content → Panel`. Base UI adds an `Indicator` part with no Radix equivalent.
## Tabs.Root → Tabs.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `defaultValue` | `string` / — | `defaultValue` | Signature changed: Base UI value type is `Tabs.Tab.Value` (`any`), default `0` (first tab active by default; Radix has no default active tab). Strings still work unchanged. |
| `value` | `string` / — | `value` | Same shape for string values; type widened to `any`. |
| `onValueChange` | `(value: string) => void` / — | `onValueChange` | Signature changed: `(value: Tabs.Tab.Value, eventDetails: Tabs.Root.ChangeEventDetails) => void`. |
| `orientation` | `"horizontal" \| "vertical"` / `"horizontal"` | `orientation` (default `'horizontal'`) | Same. |
| `dir` | `"ltr" \| "rtl"` / — | — dropped | Use DOM `dir` / `DirectionProvider`. |
| `activationMode` | `"automatic" \| "manual"` / `"automatic"` | moved + renamed: `List.activateOnFocus` (boolean, default `false`) | Moved from Root to List and inverted DEFAULT: Radix defaults to automatic, Base UI 1.6.0 defaults to `false` (manual). To preserve Radix default behavior set `<Tabs.List activateOnFocus>`; `activationMode="manual"` → omit. |
## Tabs.List → Tabs.List
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `loop` | `boolean` / `true` | `loopFocus` (default `true`) | Renamed. |
## Tabs.Trigger → Tabs.Tab
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` (+ `nativeButton`, default `true`) | Signature changed. |
| `value` (required) | `string` / — | `value` (required) | Type widened to `Tabs.Tab.Value` (`any`); strings unchanged. |
| `disabled` | `boolean` / `false` | `disabled` | Same. |
## Tabs.Content → Tabs.Panel
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `value` (required) | `string` / — | `value` (required) | Type widened; strings unchanged. |
| `forceMount` | `true \| undefined` / — | `keepMounted` (boolean, default `false`) | Renamed. Hidden panels stay in DOM with `data-hidden`. |
## Base UI only props worth knowing
- `Tabs.Indicator`: new part, a `<span>` that tracks the active tab for sliding-highlight UIs; `renderBeforeHydration` (default `false`) for SSR-flash avoidance. Exposes the `--active-tab-*` CSS vars below.
- `List.activateOnFocus` (see above).
- `Tab.nativeButton`, state-function `className`/`style` on all parts.
## Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `Trigger [data-state="active" \| "inactive"]` | `Tab [data-active]` (presence) | Renamed. Inactive = absence of `data-active`. |
| `Content [data-state="active" \| "inactive"]` | `Panel [data-hidden]` (presence when hidden) | Inverted polarity: Radix marks the active state, Base UI marks the hidden state. |
| `[data-orientation]` (all parts) | `[data-orientation]` (Root, List, Tab, Panel, Indicator) | Same. |
| `Trigger [data-disabled]` | `Tab [data-disabled]` | Same. |
| — | `[data-activation-direction]` (`'left' \| 'right' \| 'up' \| 'down' \| 'none'`, all parts) | Base UI only: direction of the last tab change, useful for directional animations. |
| — | `Panel [data-index]`, `[data-starting-style]`, `[data-ending-style]` | Base UI only. |
## CSS var mapping
Radix Tabs exposes no CSS variables. Base UI only (on `Indicator`): `--active-tab-left`, `--active-tab-right`, `--active-tab-top`, `--active-tab-bottom`, `--active-tab-width`, `--active-tab-height`.
---
# toggle
Part mapping: `Toggle.Root → Toggle` (single-part; Base UI export is directly callable, no `.Root`).
## Toggle.Root → Toggle
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` (+ `nativeButton`, default `true`) | Signature changed. |
| `defaultPressed` | `boolean` / — | `defaultPressed` (default `false`) | Same. |
| `pressed` | `boolean` / — | `pressed` | Same. |
| `onPressedChange` | `(pressed: boolean) => void` / — | `onPressedChange` | Signature changed: `(pressed: boolean, eventDetails: Toggle.ChangeEventDetails) => void`. |
| `disabled` | `boolean` / — | `disabled` (default `false`) | Same. |
## Base UI only props worth knowing
- `value?: string`: identifies the toggle inside a Base UI `ToggleGroup` (this replaces Radix `ToggleGroup.Item`'s `value`, see toggle-group below).
- `nativeButton` (default `true`), state-function `className`/`style`.
## Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `[data-state="on" \| "off"]` | `[data-pressed]` (presence) | Renamed. Off = absence of `data-pressed`. |
| `[data-disabled]` | `[data-disabled]` | Same. |
## CSS var mapping
None on either side.
---
# toggle-group
Part mapping: `ToggleGroup.Root → ToggleGroup` (callable single export), `ToggleGroup.Item → Toggle` (Base UI reuses the Toggle primitive as group items).
## ToggleGroup.Root → ToggleGroup
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `type` (required) | `"single" \| "multiple"` / — | `multiple` (boolean, default `false`) | Signature changed, same pattern as accordion. |
| `value` | `string` (single) or `string[]` (multiple) / — | `value` | Signature changed: always `readonly Value[]` (array), even single mode. `value="bold"` → `value={["bold"]}`. |
| `defaultValue` | `string` or `string[]` / — | `defaultValue` | Same array caveat. |
| `onValueChange` | `(value: string) => void` or `(value: string[]) => void` / — | `onValueChange` | Signature changed: `(groupValue: Value[], eventDetails: ToggleGroup.ChangeEventDetails) => void`. Always an array; single mode with nothing pressed = `[]` (Radix single mode signals this as `""`). |
| `disabled` | `boolean` / `false` | `disabled` (default `false`) | Same. |
| `rovingFocus` | `boolean` / `true` | — dropped | Roving focus is always on in Base UI; no opt-out. If you relied on `rovingFocus={false}` (every item tabbable), there is no direct workaround. |
| `orientation` | `"horizontal" \| "vertical"` / `undefined` | `orientation` (default `'horizontal'`) | Same name; Base UI has an explicit default. |
| `dir` | `"ltr" \| "rtl"` / — | — dropped | Use DOM `dir` / `DirectionProvider`. |
| `loop` | `boolean` / `true` | `loopFocus` (default `true`) | Renamed. |
## ToggleGroup.Item → Toggle
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` (+ `nativeButton`, default `true`) | Signature changed. |
| `value` (required) | `string` / — | `value` | Same meaning; on Base UI's `Toggle` it is optional in the type but required in practice for group membership. |
| `disabled` | `boolean` / — | `disabled` (default `false`) | Same. |
Note: the item also gains the full standalone `Toggle` API (`pressed`, `defaultPressed`, `onPressedChange` with `eventDetails`) since it IS the Toggle primitive; inside a group the group value normally drives pressed state.
## Base UI only props worth knowing
- `multiple` (covered above) and the always-array value model.
- Items are plain `Toggle`s, so per-item `onPressedChange` is available.
- State-function `className`/`style`.
## Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `Item [data-state="on" \| "off"]` | `Toggle [data-pressed]` (presence) | Renamed. |
| `Item [data-disabled]` | `Toggle [data-disabled]` | Same. |
| `Root/Item [data-orientation]` | `ToggleGroup [data-orientation]` | On the group only; items (Toggles) do not emit it. |
| — | `ToggleGroup [data-disabled]`, `[data-multiple]` | Base UI only. |
## CSS var mapping
None on either side.
---
# toolbar
Part mapping: `Root → Root`, `Button → Button`, `Link → Link`, `Separator → Separator`. `Toolbar.ToggleGroup`/`Toolbar.ToggleItem` are DROPPED as dedicated parts: compose the standalone `ToggleGroup` with `<Toolbar.Button render={<Toggle />} value="...">` as items (Base UI docs pattern). Base UI adds `Group` and `Input` parts with no Radix equivalent.
## Toolbar.Root → Toolbar.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `orientation` | `"horizontal" \| "vertical"` / `"horizontal"` | `orientation` (default `'horizontal'`) | Same. |
| `dir` | `"ltr" \| "rtl"` / — | — dropped | Use DOM `dir` / `DirectionProvider`. |
| `loop` | `boolean` / `true` | `loopFocus` (default `true`) | Renamed. |
## Toolbar.Button → Toolbar.Button
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` (+ `nativeButton`, default `true`) | Signature changed. |
## Toolbar.Link → Toolbar.Link
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. Both render `<a>`. |
## Toolbar.ToggleGroup → ToggleGroup (standalone, composed)
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `type` (required) | `"single" \| "multiple"` / — | `multiple` (boolean, default `false`) | Moved: use the standalone `ToggleGroup` component inside `Toolbar.Root`; same mapping as toggle-group above. |
| `value` / `defaultValue` | `string` or `string[]` / — | `value` / `defaultValue` on `ToggleGroup` | Always an array (see toggle-group). |
| `onValueChange` | `(value: string \| string[]) => void` / — | `onValueChange` on `ToggleGroup` | `(groupValue: Value[], eventDetails) => void`. |
| `disabled` | `boolean` / `false` | `disabled` on `ToggleGroup` | Same. |
## Toolbar.ToggleItem → Toolbar.Button render={<Toggle />}
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | — | Moved: the composition IS the render prop: `<Toolbar.Button render={<Toggle />} value="bold" />`. Toolbar.Button supplies toolbar focus behavior, Toggle supplies pressed state. |
| `value` (required) | `string` / — | `value` (on the composed element) | Same. |
| `disabled` | `boolean` / — | `disabled` (on `Toolbar.Button`, default `false`) | Same; note `focusableWhenDisabled` defaults to `true` (disabled items stay focusable, Radix disabled items are not). |
## Toolbar.Separator → Toolbar.Separator
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| — | — | `orientation` | Base UI only: defaults to the OPPOSITE of the toolbar's orientation (horizontal toolbar → vertical separator), which matches Radix's automatic behavior; usually omit. |
## Base UI only props worth knowing
- `Root.disabled`: disables the entire toolbar (no Radix equivalent).
- `Toolbar.Group` (new part): groups related items, with a group-level `disabled` (default `false`).
- `Toolbar.Input` (new part): `<input>` wired into toolbar arrow-key navigation; `defaultValue`, `disabled` (default `false`), `focusableWhenDisabled` (default `true`).
- `Button.disabled` (default `false`) + `Button.focusableWhenDisabled` (default `true`): disabled buttons remain focusable for discoverability; set `focusableWhenDisabled={false}` for Radix-like behavior.
- `Button.nativeButton` (default `true`), state-function `className`/`style` on all parts.
## Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `[data-orientation]` (Root, Button, ToggleGroup, ToggleItem, Separator) | `[data-orientation]` (Root, Button, Link, Input, Group, Separator) | Same; Separator's value is perpendicular to the toolbar. |
| `ToggleItem [data-state="on" \| "off"]` | `[data-pressed]` (presence, from the composed `Toggle`) | Renamed. |
| `ToggleItem [data-disabled]` | `[data-disabled]` (Root, Button, Input, Group) | Same. |
| — | `Button/Input [data-focusable]` | Base UI only: present when focusable-while-disabled. |
## CSS var mapping
None on either side.
display-misc.md›
# Radix 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 `className` and `style` also accept a function of the part's `State` object.
- Every Base part exposes `Part.Props` and `Part.State` types (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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed (see header). |
| `value` | `number \| null` / - | `value` | Same. Required in Base UI (default `null`). `null` = indeterminate in both. |
| `max` | `number` / - | `max` | Same. Base UI default `100`; Base UI also adds `min` (default `0`). |
| `getValueLabel` | `(value: number, max: number) => string` / - | `getAriaValueText` | Renamed + signature changed: Base UI is `(formattedValue: string \| null, value: number \| null) => string`. Percent math is gone; use `format`/`locale` for formatting instead. |
## Progress.Indicator → Progress.Indicator
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Only prop. Nest inside `Progress.Track`; width is set by the primitive. |
### 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>`, `children` render fn `(formattedValue, value) => ReactNode`).
### Data attributes
| Radix | Base UI |
| --- | --- |
| `[data-state="loading"]` | `[data-progressing]` (boolean-presence attrs replace the enum) |
| `[data-state="complete"]` | `[data-complete]` |
| `[data-state="indeterminate"]` | `[data-indeterminate]` |
| `[data-value]`, `[data-max]` | Dropped. Read `value` in a `className`/`style` state function or set your own attribute. |
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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `type` | `"auto" \| "always" \| "scroll" \| "hover"` / `"hover"` | Dropped | Visibility is CSS-driven: style Scrollbar `opacity` against `[data-hovering]`/`[data-scrolling]` (hover/scroll behavior), or always-visible CSS for `"always"` (+ `keepMounted` on Scrollbar). `"auto"` is the default mount behavior (scrollbar only mounts when scrollable). |
| `scrollHideDelay` | `number` / `600` | Dropped | Reproduce with a CSS `transition-delay` on the scrollbar's opacity transition. |
| `dir` | `"ltr" \| "rtl"` / - | Dropped | Base UI reads direction from the DOM (`dir` attribute) / its DirectionProvider utility; no per-component prop. |
| `nonce` | `string` / - | Dropped | No documented CSP nonce equivalent. |
## ScrollArea.Viewport → ScrollArea.Viewport
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Same part role (the scrollable container). Wrap children in `ScrollArea.Content` when horizontal scrolling matters. |
## ScrollAreaScrollbar → ScrollArea.Scrollbar
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `forceMount` | `boolean` / - | `keepMounted` | Renamed; `boolean`, default `false`. Keeps the element in the DOM when the viewport is not scrollable. |
| `orientation` | `"horizontal" \| "vertical"` / `"vertical"` | `orientation` | Same, same default. |
## ScrollAreaThumb → ScrollArea.Thumb
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Only prop on both sides. |
## ScrollArea.Corner → ScrollArea.Corner
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Only prop on both sides. |
### Base UI only props worth knowing
- Root: `overflowEdgeThreshold` (`number | Partial<{ xStart; xEnd; yStart; yEnd }>`, default `0`), threshold before the overflow edge attributes flip.
- New part: `ScrollArea.Content` (div inside Viewport; same overflow data attributes as Root).
### Data attributes
| Radix | Base UI |
| --- | --- |
| Scrollbar `[data-state="visible" \| "hidden"]` | Dropped. Use `[data-hovering]`, `[data-scrolling]`, and `[data-has-overflow-x/y]` on Scrollbar to drive visibility styles. |
| Scrollbar/Thumb `[data-orientation]` | Same (`data-orientation` on Scrollbar and Thumb). |
| - | New, on Root/Content/Viewport/Scrollbar: `data-has-overflow-x`, `data-has-overflow-y`, `data-overflow-x-start/end`, `data-overflow-y-start/end`, `data-scrolling`; Scrollbar also `data-hovering`. |
### CSS variables
Radix's scroll-area docs list no CSS variables (its implementation ships undocumented `--radix-scroll-area-thumb-*`/`corner-*` vars). Base UI documents:
| Base UI variable | Where |
| --- | --- |
| `--scroll-area-corner-width`, `--scroll-area-corner-height` | Root |
| `--scroll-area-thumb-width`, `--scroll-area-thumb-height` | Scrollbar |
| `--scroll-area-overflow-x-start/end`, `--scroll-area-overflow-y-start/end` | Viewport (pixel distance from each edge, great for scroll fades) |
---
# separator
Part mapping: `Separator.Root` → `Separator` (callable single part, no `.Root`).
## Separator.Root → Separator
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `orientation` | `"horizontal" \| "vertical"` / `"horizontal"` | `orientation` | Same, same default (`Orientation` type). |
| `decorative` | `boolean` / - | Dropped | Base UI's separator is always semantic (`role="separator"`). For a purely visual rule, render a plain `<div aria-hidden="true">` or use a CSS border instead. |
### 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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Only prop on both sides. Base Root can also take plain children (e.g. initials) with no Image/Fallback. |
## Avatar.Image → Avatar.Image
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `onLoadingStatusChange` | `(status: "idle" \| "loading" \| "loaded" \| "error") => void` / - | `onLoadingStatusChange` | Same name, same `ImageLoadingStatus` union. |
## Avatar.Fallback → Avatar.Fallback
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `delayMs` | `number` / - | `delay` | Renamed, same meaning (ms to wait before showing the 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:
| Radix part | Base UI part |
| --- | --- |
| `Toast.Provider` | `Toast.Provider` (props differ heavily) |
| `Toast.Viewport` | `Toast.Portal` + `Toast.Viewport` (Portal is new; appends to `<body>` by default) |
| `Toast.Root` | `Toast.Root` (requires `toast` object; typically wraps new `Toast.Content`) |
| `Toast.Title` | `Toast.Title` (renders `<h2>`) |
| `Toast.Description` | `Toast.Description` (renders `<p>`) |
| `Toast.Action` | `Toast.Action` (rendered per-toast; props can come from `toast.actionProps`) |
| `Toast.Close` | `Toast.Close` |
| - | New: `Toast.Content`, `Toast.Positioner` + `Toast.Arrow` (anchored toasts), `Toast.createToastManager`, `Toast.useToastManager` |
## Toast.Provider → Toast.Provider
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `duration` | `number` / `5000` | `timeout` | Renamed. Same default (`5000`, `0` disables auto-dismiss). Per-toast override moved to `add({ timeout })`. |
| `label` (required) | `string` / `"Notification"` | Dropped | Base UI handles screen reader announcements internally; per-toast urgency via `priority: 'low' \| 'high'` in `add()`. |
| `swipeDirection` | `"right" \| "left" \| "up" \| "down"` / `"right"` | Moved | Now `swipeDirection` on `Toast.Root`; accepts a single value or an array, default `['down', 'right']`. |
| `swipeThreshold` | `number` / `50` | Dropped | Not configurable. Opt elements out of swipe with the `data-base-ui-swipe-ignore` attribute. |
| `announcerContainer` | `Element \| DocumentFragment` / `document.body` | Dropped | Closest analog is `Toast.Portal container` for where the viewport renders. |
## Toast.Viewport → Toast.Portal + Toast.Viewport
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | On both Portal and Viewport. |
| `hotkey` | `string[]` / `["F8"]` | Dropped | Base UI hard-wires F6 to focus the viewport landmark; not configurable. |
| `label` | `string` / `"Notifications ({hotkey})"` | Dropped | Landmark labelling handled internally. |
Base UI only: `Portal.container` (`HTMLElement | ShadowRoot | ref | null`).
## Toast.Root → Toast.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `type` | `"foreground" \| "background"` / `"foreground"` | `priority` in `add()` options | Moved + renamed: `foreground` ≈ `priority: 'high'` (announced urgently), `background` ≈ `'low'` (default). Careful: Base UI's `toast.type` is a different concept (a free-form styling category like `'success'`, surfaced as `data-type`). |
| `duration` | `number` / - | `timeout` in `add()` options | Moved + renamed; per-toast override of Provider `timeout`. |
| `defaultOpen` | `boolean` / `true` | Dropped | Open state lives in the manager. Create with `add()`, remove with `close(id)`. |
| `open` | `boolean` / - | Dropped | Same as above; there is no controlled-open mode. `add({ id })` upserts an existing toast in place. |
| `onOpenChange` | `(open: boolean) => void` / - | Dropped (workaround) | Use `onClose` / `onRemove` callbacks in the toast object (`add()` options). |
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` / - | Dropped | Esc-to-close still works when focus is in the viewport, but is not interceptable. |
| `onPause` / `onResume` | `() => void` / - | Dropped | Timers still pause on hover, focus, and window blur automatically, but there are no callbacks. |
| `onSwipeStart` / `onSwipeMove` / `onSwipeEnd` / `onSwipeCancel` | `(event: SwipeEvent) => void` / - | Dropped (workaround) | Swiping is styled, not scripted: `[data-swiping]`, `[data-swipe-direction]` and `--toast-swipe-movement-x/y` replace the event hooks. |
| `forceMount` | `boolean` / - | Dropped | Roots render from the `toasts` array; exit animations get `data-ending-style` + `toast.transitionStatus: 'ending'` before removal (`onRemove` fires after). |
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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Only prop. Base renders `<h2>` (Radix rendered `<div>`); pass `render={<div />}` to keep a div. Content usually comes from `toast.title`. |
## Toast.Description → Toast.Description
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Only prop. Base renders `<p>`. Content usually comes from `toast.description`. |
## Toast.Action → Toast.Action
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `altText` (required) | `string` / - | Dropped | No equivalent prop. When creating toasts via the manager, pass the button's props (including handlers and aria attributes) through `add({ actionProps })`. |
Base UI only: `nativeButton` (`boolean`, default `true`, set `false` when `render` is not a button).
## Toast.Close → Toast.Close
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
Base UI only: `nativeButton` (as on Action).
### Base UI only props worth knowing
- Provider: `limit` (`number`, default `3`; overflowing toasts get `data-limited` + `inert` instead of being removed), `toastManager` (from `Toast.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.Arrow` for anchored toasts (full popup positioning surface: `anchor`, `side` default `'top'`, `align`, `sideOffset`, `alignOffset`, `collisionAvoidance`, `collisionBoundary`, `collisionPadding`, `arrowPadding`, `sticky`, `positionMethod`, `disableAnchorTracking`).
### Data attributes
| Radix | Base UI |
| --- | --- |
| Root `[data-state="open" \| "closed"]` | `data-starting-style` / `data-ending-style` (CSS transition hooks) |
| Root `[data-swipe="start" \| "move" \| "cancel" \| "end"]` | `[data-swiping]` while swiping; `"end"` ≈ `[data-ending-style][data-swipe-direction=...]` |
| Root `[data-swipe-direction]` (`up/down/left/right`) | Same name and values |
| - | New: Root `data-expanded`, `data-limited`, `data-type`; Viewport `data-expanded`; Content `data-behind`, `data-expanded`; Title/Description/Close/Action `data-type`; Positioner/Arrow `data-side`, `data-align`, `data-anchor-hidden`/`data-uncentered` |
### CSS variables
| Radix | Base UI |
| --- | --- |
| `--radix-toast-swipe-move-x` / `--radix-toast-swipe-move-y` | `--toast-swipe-movement-x` / `--toast-swipe-movement-y` |
| `--radix-toast-swipe-end-x` / `--radix-toast-swipe-end-y` | Dropped; animate dismissal from `[data-ending-style][data-swipe-direction=...]` using the movement vars |
| - | New on Root: `--toast-index`, `--toast-offset-y`, `--toast-height`; Viewport: `--toast-frontmost-height`; Positioner: `--anchor-width/height`, `--available-width/height`, `--transform-origin` |
---
# 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:
| Radix part | Base UI part |
| --- | --- |
| `Form.Root` | `Form` (callable, no `.Root`) |
| `Form.Field` | `Field.Root` |
| `Form.Label` | `Field.Label` |
| `Form.Control` | `Field.Control` (or any Base UI input component: Input, Checkbox, Select, ... work inside Field out of the box) |
| `Form.Message` | `Field.Error` (validation errors); `Field.Description` for plain hint text |
| `Form.ValidityState` | `Field.Validity` |
| `Form.Submit` | Dropped; use a plain `<button type="submit">` |
| - | New: `Fieldset.Root` + `Fieldset.Legend`, `Field.Item` (per-item wrapper in checkbox/radio groups) |
## Form.Root → Form
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `onClearServerErrors` | `() => void` / - | Dropped (workaround) | The server-error model changed: pass an `errors` object (keys = `Field.Root` `name`, values = message(s)) to `Form`; clear your own error state in `onFormSubmit` (Base calls `preventDefault()` for you) or in `onValueChange` per field. |
Base UI only on Form: `errors` (`Errors`), `onFormSubmit` (`(formValues, eventDetails) => void`), `validationMode` (`'onSubmit' | 'onBlur' | 'onChange'`, default `'onSubmit'`), `actionsRef` (`{ validate(fieldName?) }`).
## Form.Field → Field.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `name` (required) | `string` / - | `name` | Same purpose (submission identity + matching `Form errors` keys); optional in Base UI and takes precedence over `name` on `Field.Control`. |
| `serverInvalid` | `boolean` / - | Dropped (workaround) | Either supply the message via `Form errors={{ [name]: message }}` (field becomes invalid and `Field.Error` shows it), or force state with the `invalid` boolean prop on `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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. Auto-association with the control is preserved. |
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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. For composite widgets, skip Control entirely: Base UI inputs (Input, Checkbox, Select, ...) wire into `Field.Root` directly, which Radix Form could not do. |
Base UI only: `defaultValue` (`string | number | string[]`), `onValueChange` (`(value, eventDetails) => void`).
## Form.Message → Field.Error
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Signature changed. |
| `match` | `'badInput' \| 'patternMismatch' \| 'rangeOverflow' \| 'rangeUnderflow' \| 'stepMismatch' \| 'tooLong' \| 'tooShort' \| 'typeMismatch' \| 'valid' \| 'valueMissing' \| ((value, formData) => boolean \| Promise<boolean>)` / - | `match` | Signature changed: Base UI is `boolean \| 'valid' \| 'badInput' \| 'customError' \| 'patternMismatch' \| 'rangeOverflow' \| 'rangeUnderflow' \| 'stepMismatch' \| 'tooLong' \| 'tooShort' \| 'typeMismatch' \| 'valueMissing'`. The function form is gone: move custom rules to `validate` on `Field.Root` (returns error string(s)); an Error without `match` then displays them. `'customError'` matches `validate` failures. |
| `forceMatch` | `boolean` / `false` | `match={true}` | Renamed/absorbed: `match` accepting `true` always shows the message (the documented hook for external libraries and server errors). |
| `name` | `string` / - | Dropped | `Field.Error` cannot target a field from outside; it must be nested in the owning `Field.Root`. |
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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `children` | `(validity: ValidityState \| undefined) => React.ReactNode` / - | `children` (required) | Signature changed: `(state: Field.Validity.State) => React.ReactNode` where the native flags live at `state.validity.*` (plus `state.errors`, `state.error`, `state.value`, `state.initialValue`). |
| `name` | `string` / - | Dropped | Must be nested inside `Field.Root`. |
## Form.Submit → (none)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | Dropped | No submit part; render a plain `<button type="submit">` (or the styled Button component). |
## (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 (`validationMode` on Form or per Field, `validationDebounceTime`).
- `actionsRef` imperative `validate()` on both Form and Field.Root.
- `Field.Item` groups a single checkbox/radio inside a group with its own label/description (`disabled` prop).
- Focus is moved to the first invalid field on submit, matching Radix behavior.
### Data attributes
| Radix (Field/Label/Control/Message) | Base UI (all Field parts: Root, Item, Label, Control, Description, Error) |
| --- | --- |
| `[data-valid]` | `[data-valid]` (same) |
| `[data-invalid]` | `[data-invalid]` (same) |
| - | New: `data-dirty`, `data-touched`, `data-filled`, `data-focused`, `data-disabled`; Error also gets `data-starting-style`/`data-ending-style`. |
### 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.
form-controls.md›
# Radix 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`, default `false`) -> `render` (Base UI, `ReactElement | ((props, state) => ReactElement)`). Every Base UI part accepts `render`, plus `className`/`style` as either plain values or state callbacks (`(state) => ...`).
- Base UI parts that render interactive elements accept `nativeButton` (tells Base UI whether the `render` target is a native `<button>`), which has no Radix equivalent.
- Callbacks fire with a second `eventDetails` argument (`{ reason, event, cancel(), allowPropagation(), isCanceled, isPropagationAllowed, trigger }`). `eventDetails.cancel()` replaces Radix's `event.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 `dir` props have no Base UI per-component equivalent, direction comes from `DirectionProvider` (or the `dir` HTML 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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultValue` | `string`, no default | `defaultValue: Value[] \| Value \| null` | Same name, widened type. Values can be any type (objects supported), arrays for `multiple`. |
| `value` | `string`, no default | `value: Value[] \| Value \| null` | Same name, widened type. `null` means "no value" (placeholder shown). |
| `onValueChange` | `(value: string) => void` | `onValueChange: (value: Value[] \| Value \| null, eventDetails: Select.Root.ChangeEventDetails) => void` | Signature changed: second `eventDetails` arg with `reason` (`'trigger-press' \| 'outside-press' \| 'escape-key' \| 'window-resize' \| 'item-press' \| 'focus-out' \| 'list-navigation' \| 'cancel-open' \| 'none'`) and `cancel()`. |
| `defaultOpen` | `boolean`, no default | `defaultOpen: boolean`, default `false` | Same. |
| `open` | `boolean`, no default | `open: boolean` | Same. |
| `onOpenChange` | `(open: boolean) => void` | `onOpenChange: (open: boolean, eventDetails: Select.Root.ChangeEventDetails) => void` | Signature changed: added `eventDetails` (same reason union as above). Radix Content's `onEscapeKeyDown`/`onPointerDownOutside` interception moves here (check `eventDetails.reason === 'escape-key'` / `'outside-press'`, call `eventDetails.cancel()` to keep open). |
| `dir` | `"ltr" \| "rtl"`, no default | dropped | Use Base UI `DirectionProvider` or the `dir` attribute on an ancestor. |
| `name` | `string`, no default | `name: string` | Same (Base UI renders a hidden `<input>`). |
| `disabled` | `boolean`, no default | `disabled: boolean`, default `false` | Same. |
| `required` | `boolean`, no default | `required: boolean`, default `false` | Same. |
Base UI `Select.Root` renders no HTML element (Radix Root doesn't either).
## Select.Trigger → Select.Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | See global conventions. Base UI Trigger renders `<button>` by default; `nativeButton` defaults to `true` here, set it to `false` when rendering a non-button via `render`. |
| (none) | - | `disabled: boolean` | Base UI allows disabling just the trigger. |
## Select.Value → Select.Value
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. |
| `placeholder` | `ReactNode`, no default | `placeholder: React.ReactNode` | Same name. Behavior change: Radix `Value` renders the selected Item's `ItemText` content; Base UI renders the raw value string unless you pass `items` on Root or a `children` function (`(value) => ReactNode`). If your item labels differ from values, supply `items` on Root or format via `children`. |
## Select.Icon → Select.Icon
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. Base UI Icon exposes `data-popup-open` for rotate-when-open styling. |
## Select.Portal → Select.Portal
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `container` | `HTMLElement`, default `document.body` | `container: HTMLElement \| ShadowRoot \| React.RefObject<...> \| null` | Same concept, type widened (accepts refs and ShadowRoot). Base UI Portal renders a `<div>` and accepts `className`/`style`/`render`. |
## 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.
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` (on Positioner and/or Popup) | Same pattern. |
| `position` | `"item-aligned" \| "popper"`, default `"item-aligned"` | `alignItemWithTrigger: boolean` on Positioner, default `true` | Signature changed: enum becomes boolean. `"item-aligned"` -> `alignItemWithTrigger` (true, the default), `"popper"` -> `alignItemWithTrigger={false}`. Base UI auto-disables it when space is insufficient or on touch input. |
| `side` | `"top" \| "right" \| "bottom" \| "left"`, default `"bottom"` | `side: Side` on Positioner, default `'bottom'` | Moved. Base UI adds `'inline-start' \| 'inline-end'` logical values. Only applies when `alignItemWithTrigger` is off (as with Radix popper mode). |
| `sideOffset` | `number`, default `0` | `sideOffset: number \| OffsetFunction` on Positioner, default `0` | Moved, type widened (accepts a function of `{ side, align, anchor, positioner }`). |
| `align` | `"start" \| "center" \| "end"`, default `"start"` | `align: Align` on Positioner, default `'center'` | Moved. Default differs: Radix `"start"` vs Base UI `'center'`. Pass `align="start"` explicitly to preserve Radix behavior. |
| `alignOffset` | `number`, default `0` | `alignOffset: number \| OffsetFunction` on Positioner, default `0` | Moved, type widened. |
| `avoidCollisions` | `boolean`, default `true` | `collisionAvoidance: CollisionAvoidance` on Positioner | Signature changed: boolean becomes a config object `{ side: 'flip' \| 'shift' \| 'none', align: 'flip' \| 'shift' \| 'none', fallbackAxisSide: 'start' \| 'end' \| 'none' }`. `avoidCollisions={false}` ~ `collisionAvoidance={{ side: 'none', align: 'none', fallbackAxisSide: 'none' }}`. |
| `collisionBoundary` | `Boundary`, default `[]` | `collisionBoundary: Boundary` on Positioner, default `'clipping-ancestors'` | Moved, default differs (Radix default is the viewport, Base UI defaults to clipping ancestors). |
| `collisionPadding` | `number \| Padding`, default `10` | `collisionPadding: Padding` on Positioner, default `5` | Moved, default differs (10 -> 5). |
| `arrowPadding` | `number`, default `0` | `arrowPadding: number` on Positioner, default `5` | Moved, default differs (0 -> 5). |
| `sticky` | `"partial" \| "always"`, default `"partial"` | `sticky: boolean` on Positioner, default `false` | Signature changed and semantics differ: Base UI `sticky` keeps the popup in the viewport after the anchor scrolls out of view. There is no `"always"` equivalent. |
| `hideWhenDetached` | `boolean`, default `false` | dropped (workaround) | No prop. Style on `data-anchor-hidden` (present on Positioner when the anchor is hidden), e.g. `[data-anchor-hidden] { visibility: hidden }`. |
| `onCloseAutoFocus` | `(event: Event) => void` | `finalFocus` on Popup | Signature changed: instead of preventing default in an event handler, pass `finalFocus` as `boolean \| RefObject \| ((closeType: InteractionType) => boolean \| void \| HTMLElement \| null)`. `false` = don't move focus (the `preventDefault()` equivalent), a ref/element = focus that. |
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` | moved to `Root.onOpenChange` | Check `eventDetails.reason === 'escape-key'`; call `eventDetails.cancel()` to prevent close. |
| `onPointerDownOutside` | `(event: PointerEvent) => void` | moved to `Root.onOpenChange` | Check `eventDetails.reason === 'outside-press'`; call `eventDetails.cancel()` to prevent close. |
## Select.Viewport → Select.List (renamed)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Renamed part, no other props on either side. Radix required `<ScrollUpButton>`/`<Viewport>`/`<ScrollDownButton>` as siblings inside Content; in Base UI, `List` and the scroll arrows are children of `Popup`. |
## Select.Item → Select.Item
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. Also `nativeButton` (default `false`, Base UI Item renders a `<div>`). |
| `value` | `string`, required | `value: any`, default `null` | Same name, widened type (objects allowed, see Root `isItemEqualToValue` / `itemToString*`). `null` value marks the placeholder item. |
| `disabled` | `boolean`, no default | `disabled: boolean`, default `false` | Same. |
| `textValue` | `string`, no default | `label: string` | Renamed. Both drive typeahead text matching, defaulting to the item's text content. |
## Select.ItemText → Select.ItemText
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. Note element change: Radix renders `<span>`, Base UI renders `<div>`. |
## Select.ItemIndicator → Select.ItemIndicator
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. |
| (implicit conditional mount) | - | `keepMounted: boolean` | Base UI unmounts when unselected by default, same as Radix. `keepMounted` keeps it in the DOM (Radix had no `forceMount` on select's ItemIndicator). |
## Select.ScrollUpButton / ScrollDownButton → Select.ScrollUpArrow / ScrollDownArrow (renamed)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Renamed parts. Base UI adds `keepMounted: boolean` (default `false`) to keep the arrow in the DOM while the popup is not scrollable. Base UI arrows do not render on touch input. |
## Select.Group → Select.Group
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same. |
## Select.Label → Select.GroupLabel (renamed)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Renamed. Do NOT map to Base UI `Select.Label`, which is a new part that labels the select trigger itself (rendered outside the popup). |
## Select.Separator → Select.Separator
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same. Base UI adds `orientation: Orientation`, default `'horizontal'`. |
## Select.Arrow → Select.Arrow
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. |
| `width` | `number`, default `10` | dropped | Size the arrow with CSS; Base UI Arrow renders a `<div>` you fill with your own SVG. |
| `height` | `number`, default `5` | dropped | Same as above. |
## Base UI only props worth knowing (select)
- `Root.multiple: boolean` (default `false`): multi-select with `Value[]` values, no Radix equivalent.
- `Root.items`: `Record<string, ReactNode> | { label, value }[] | Group[]`, lets `Select.Value` render labels instead of raw values.
- `Root.isItemEqualToValue`, `Root.itemToStringLabel`, `Root.itemToStringValue`: object-value support.
- `Root.modal: boolean` (default `true`): scroll lock + outside pointer blocking; Radix select was always modal-ish, set `modal={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` (default `true`).
- 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)
| Radix | Base UI |
| --- | --- |
| Trigger `data-state="open" \| "closed"` | Trigger `data-popup-open` (presence), plus `data-pressed`, `data-popup-side` |
| Trigger `data-placeholder` | Trigger/Value `data-placeholder` (same) |
| Trigger `data-disabled` | Trigger `data-disabled` (same), plus `data-readonly`, `data-required`, Field attrs |
| Content `data-state="open" \| "closed"` | Positioner/Popup `data-open` / `data-closed` (presence) |
| Content `data-side` (`left/right/bottom/top`) | Positioner/Popup `data-side` (`none/top/bottom/left/right/inline-start/inline-end`) |
| Content `data-align` | Positioner/Popup `data-align` (same values) |
| Item `data-state="checked" \| "unchecked"` | Item `data-selected` (presence, no unchecked token) |
| Item `data-highlighted` | Item `data-highlighted` (same) |
| Item `data-disabled` | Item `data-disabled` (same) |
| (none) | Popup/Backdrop/ItemIndicator/ScrollArrows `data-starting-style` / `data-ending-style` (animation hooks) |
| (none) | Positioner `data-anchor-hidden`, ScrollArrows `data-direction` / `data-visible` |
## CSS variable mapping (select)
All Base UI vars are set on `Select.Positioner` (Radix set them on Content, popper mode only):
| Radix | Base UI |
| --- | --- |
| `--radix-select-trigger-width` | `--anchor-width` |
| `--radix-select-trigger-height` | `--anchor-height` |
| `--radix-select-content-available-width` | `--available-width` |
| `--radix-select-content-available-height` | `--available-height` |
| `--radix-select-content-transform-origin` | `--transform-origin` |
---
# 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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | See global conventions, pair with `nativeButton` when rendering a `<button>`. |
| `defaultChecked` | `boolean \| 'indeterminate'`, no default | `defaultChecked: boolean`, default `false` | Signature changed: `'indeterminate'` is no longer a checked value. Use the separate `indeterminate: boolean` prop. |
| `checked` | `boolean \| 'indeterminate'`, no default | `checked: boolean` + `indeterminate: boolean` | Signature changed: split into two props. Radix `checked="indeterminate"` -> Base UI `indeterminate` (a checkbox can be indeterminate and unchecked/checked independently). |
| `onCheckedChange` | `(checked: boolean \| 'indeterminate') => void` | `onCheckedChange: (checked: boolean, eventDetails: Checkbox.Root.ChangeEventDetails) => void` | Signature changed: `checked` is always boolean, `eventDetails` added (`reason: 'none'`). Indeterminate transitions are managed by you via the `indeterminate` prop (or `parent` in a CheckboxGroup). |
| `disabled` | `boolean`, no default | `disabled: boolean`, default `false` | Same. |
| `required` | `boolean`, no default | `required: boolean`, default `false` | Same. |
| `name` | `string`, no default | `name: string` | Same. |
| `value` | `string`, default `"on"` | `value: string` | Same name. Radix documents the default as `"on"`; Base UI docs list no default but hidden-input submission matches native checkbox behavior (`"on"` when unset). |
## Checkbox.Indicator → Checkbox.Indicator
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. |
| `forceMount` | `boolean`, no default | `keepMounted: boolean`, default `false` | Renamed. Both keep the element in the DOM when unchecked (for animation). Base UI also renders the indicator when `indeterminate`. |
## Base UI only props worth knowing (checkbox)
- `Root.indeterminate: boolean` (default `false`): the mixed state, decoupled from `checked`.
- `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)
| Radix | Base UI |
| --- | --- |
| `data-state="checked"` | `data-checked` |
| `data-state="unchecked"` | `data-unchecked` |
| `data-state="indeterminate"` | `data-indeterminate` |
| `data-disabled` | `data-disabled` (same) |
| (none) | `data-readonly`, `data-required`, `data-valid`, `data-invalid`, `data-dirty`, `data-touched`, `data-filled`, `data-focused` (Field integration) |
| (none) | Indicator `data-starting-style` / `data-ending-style` |
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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. |
| `defaultValue` | `string`, no default | `defaultValue: Value` | Same name, widened type (any value type). |
| `value` | `string`, no default | `value: Value` | Same name, widened type. |
| `onValueChange` | `(value: string) => void` | `onValueChange: (value: Value, eventDetails: RadioGroup.ChangeEventDetails) => void` | Signature changed: added `eventDetails` (`reason: 'none'`). |
| `disabled` | `boolean`, no default | `disabled: boolean`, default `false` | Same. |
| `name` | `string`, no default | `name: string` | Same. |
| `required` | `boolean`, no default | `required: boolean`, default `false` | Same. |
| `orientation` | `enum`, default `undefined` | dropped | Base UI arrow-key navigation handles both axes automatically; there is no orientation prop (set `aria-orientation` yourself if needed for AT). |
| `dir` | `"ltr" \| "rtl"`, no default | dropped | Use `DirectionProvider`. |
| `loop` | `boolean`, default `true` | dropped | Focus wrapping is built in and not configurable. |
## RadioGroup.Item → Radio.Root (moved to Radio namespace)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern, plus `nativeButton` (default `false`). |
| `value` | `string`, required | `value: Value`, required | Same name, widened type. |
| `disabled` | `boolean`, no default | `disabled: boolean` | Same. |
| `required` | `boolean`, no default | `required: boolean` | Same. |
## RadioGroup.Indicator → Radio.Indicator (moved to Radio namespace)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. |
| `forceMount` | `boolean`, no default | `keepMounted: boolean`, default `false` | Renamed. |
## 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)
| Radix | Base UI |
| --- | --- |
| Root `data-disabled` | RadioGroup `data-disabled` (same) |
| Item/Indicator `data-state="checked"` | Radio.Root/Indicator `data-checked` |
| Item/Indicator `data-state="unchecked"` | Radio.Root/Indicator `data-unchecked` |
| Item/Indicator `data-disabled` | `data-disabled` (same) |
| (none) | `data-readonly`, `data-required`, Field attrs (`data-valid`, `data-invalid`, `data-dirty`, `data-touched`, `data-filled`, `data-focused`) |
| (none) | Indicator `data-starting-style` / `data-ending-style` |
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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern, pair with `nativeButton`. |
| `defaultChecked` | `boolean`, no default | `defaultChecked: boolean`, default `false` | Same. |
| `checked` | `boolean`, no default | `checked: boolean` | Same. |
| `onCheckedChange` | `(checked: boolean) => void` | `onCheckedChange: (checked: boolean, eventDetails: Switch.Root.ChangeEventDetails) => void` | Signature changed: added `eventDetails` (`reason: 'none'`). |
| `disabled` | `boolean`, no default | `disabled: boolean`, default `false` | Same. |
| `required` | `boolean`, no default | `required: boolean`, default `false` | Same. |
| `name` | `string`, no default | `name: string` | Same. |
| `value` | `string`, default `"on"` | `value: string` | Same name. Base UI submits `"on"` by default, matching native checkbox behavior. |
## Switch.Thumb → Switch.Thumb
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern, only prop on either side. |
## Base UI only props worth knowing (switch)
- `Root.readOnly: boolean`, `Root.uncheckedValue: string`, `Root.form: string`, `Root.inputRef`, `Root.id`, `Root.nativeButton` (default `false`).
## Data-attribute mapping (switch)
| Radix | Base UI |
| --- | --- |
| Root/Thumb `data-state="checked"` | `data-checked` |
| Root/Thumb `data-state="unchecked"` | `data-unchecked` |
| Root/Thumb `data-disabled` | `data-disabled` (same) |
| (none) | `data-readonly`, `data-required`, Field attrs (`data-valid`, `data-invalid`, `data-dirty`, `data-touched`, `data-filled`, `data-focused`) |
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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. |
| `defaultValue` | `number[]`, no default | `defaultValue: number \| number[]` | Same name, widened: a single `number` gives a single-thumb slider (no array wrapper needed). |
| `value` | `number[]`, no default | `value: number \| number[]` | Same, widened. Ranged sliders still take an array. |
| `onValueChange` | `(value: number[]) => void` | `onValueChange: (value: number \| number[], eventDetails: Slider.Root.ChangeEventDetails) => void` | Signature changed: value matches the shape you pass in (number for single), `eventDetails` added with `reason: 'input-change' \| 'track-press' \| 'drag' \| 'keyboard' \| 'none'` and `activeThumbIndex: number`. |
| `onValueCommit` | `(value: number[]) => void` | `onValueCommitted: (value: number \| number[], eventDetails: Slider.Root.CommitEventDetails) => void` | Renamed (`Commit` -> `Committed`) and signature changed (same shape/eventDetails notes as above). Base UI does not fire it if the value did not change. |
| `name` | `string`, no default | `name: string` | Same. |
| `disabled` | `boolean`, default `false` | `disabled: boolean`, default `false` | Same. |
| `orientation` | `"horizontal" \| "vertical"`, default `"horizontal"` | `orientation: Orientation`, default `'horizontal'` | Same. |
| `dir` | `"ltr" \| "rtl"`, no default | dropped | Use `DirectionProvider`. |
| `inverted` | `boolean`, default `false` | dropped (workaround) | No equivalent. For horizontal sliders, wrap in `DirectionProvider dir="rtl"` (direction-based inversion); there is no built-in way to invert a vertical slider. |
| `min` | `number`, default `0` | `min: number`, default `0` | Same. |
| `max` | `number`, default `100` | `max: number`, default `100` | Same. |
| `step` | `number`, default `1` | `step: number`, default `1` | Same. |
| `minStepsBetweenThumbs` | `number`, default `0` | `minStepsBetweenValues: number`, default `0` | Renamed (`Thumbs` -> `Values`). |
| `form` | `string`, no default | `form: string` | Same. |
## Slider.Track → Slider.Track (moved inside Control)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. Structural move: Track must now be nested in the new `Slider.Control` part, and `Thumb` moves inside `Track` (Radix had Thumb as a sibling of Track under Root). |
## Slider.Range → Slider.Indicator (renamed)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Renamed part, same role (visualizes the filled portion), still a child of Track. |
## Slider.Thumb → Slider.Thumb
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean`, `false` | `render` | Same pattern. |
| (per-thumb accessibility via aria props) | - | `index: number`, `getAriaLabel(index)`, `getAriaValueText(formattedValue, value, index)`, `aria-valuetext` | Base UI thumbs take `index` (required for SSR of multi-thumb range sliders) and a11y formatters. Also `disabled`, `inputRef`, `tabIndex`, `onFocus`/`onBlur`/`onKeyDown` forwarded to the nested `<input type="range">`. |
## (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'`, set `thumbAlignment="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` (default `10`): Page Up/Down and Shift+Arrow increment.
- `Root.format: Intl.NumberFormatOptions` and `Root.locale: Intl.LocalesArgument`: value formatting for `Slider.Value` and `aria-valuetext`.
- New parts: `Slider.Value` (renders `<output>`, `children: (formattedValues: string[], values: number[]) => ReactNode`), `Slider.Label` (auto-associated label).
## Data-attribute mapping (slider)
| Radix | Base UI |
| --- | --- |
| `data-disabled` (all parts) | `data-disabled` (same, all parts) |
| `data-orientation` (`horizontal/vertical`, all parts) | `data-orientation` (same values, all parts) |
| (none) | `data-dragging` (present on all parts while dragging) |
| (none) | Thumb `data-index` (thumb index in range sliders) |
| (none) | Field attrs on all parts (`data-valid`, `data-invalid`, `data-dirty`, `data-touched`, `data-focused`) |
No CSS variables on either side (Radix slider positions thumbs via inline styles; Base UI does the same).
menus.md›
# Radix → Base UI props mapping: menu family
Sources: radix-ui.com primitives docs (dropdown-menu, context-menu, menubar, navigation-menu) and base-ui.com `/react/components/{menu,context-menu,menubar,navigation-menu}.md`, fetched 2026-07-02.
Part-mapping ground truth (from our wrappers): `Content` → `Portal > Positioner > Popup` (side/sideOffset/align/alignOffset live on `Positioner`); `Label` → `GroupLabel`; `ItemIndicator` → `CheckboxItemIndicator`/`RadioItemIndicator`; `Sub` → `SubmenuRoot`; `SubTrigger` → `SubmenuTrigger`; navigation-menu `Viewport` → `Positioner > Popup > Viewport`, `Indicator` → `Icon`; `asChild` → `render`.
Cross-cutting rules (apply to every part below):
| Radix pattern | Base UI equivalent |
| --- | --- |
| `asChild` (`boolean`, `false`) | `render` (`ReactElement \| ((props: HTMLProps, state) => ReactElement)`). No merge-onto-child boolean; pass the element or a function. |
| `dir` (`"ltr" \| "rtl"`) on roots | Dropped everywhere. Base UI reads direction from `<DirectionProvider>` (`@base-ui-components/react/direction-provider`) or the DOM `dir` attribute. |
| `forceMount` (`boolean`) | `keepMounted` (`boolean`, `false`) on `Portal` / indicator parts. Same use case (animation/SEO), presence is CSS-driven via `data-starting-style` / `data-ending-style` instead of Radix `data-state` + forced mount. |
| `onEscapeKeyDown` / `onPointerDownOutside` / `onFocusOutside` / `onInteractOutside` (content parts) | Dropped as separate props. Use `onOpenChange(open, eventDetails)` on the Root and branch on `eventDetails.reason` (`'escape-key'`, `'outside-press'`, `'focus-out'`, ...). Call `eventDetails.cancel()` to prevent the close (replaces `event.preventDefault()`). |
| `onSelect` on items (`(event: Event) => void`; `event.preventDefault()` keeps menu open) | `onClick` (`(event: BaseUIEvent<React.MouseEvent<HTMLDivElement>>) => void`) plus `closeOnClick` (`boolean`) to control whether the menu closes. |
| `textValue` on items (`string`, typeahead) | `label` (`string`). |
| Controlled callbacks `(value) => void` | All Base UI change callbacks take a second `eventDetails` argument (`{ reason, event, cancel(), allowPropagation(), isCanceled, isPropagationAllowed, trigger }`). |
---
# dropdown-menu (Radix `DropdownMenu` → Base UI `Menu`)
## Root → Menu.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` / – | `defaultOpen` (`boolean`, `false`) | Same. |
| `open` | `boolean` / – | `open` (`boolean`) | Same. |
| `onOpenChange` | `(open: boolean) => void` / – | `onOpenChange` | Signature changed: `(open: boolean, eventDetails: Menu.Root.ChangeEventDetails) => void`. `eventDetails.reason` is one of `'trigger-hover' \| 'trigger-focus' \| 'trigger-press' \| 'outside-press' \| 'focus-out' \| 'list-navigation' \| 'escape-key' \| 'item-press' \| 'close-press' \| 'sibling-open' \| 'cancel-open' \| 'imperative-action' \| 'none'`; `eventDetails.cancel()` blocks the state change. |
| `modal` | `boolean` / `true` | `modal` (`boolean`, `true`) | Same. |
| `dir` | `"ltr" \| "rtl"` / – | – | Dropped. Use `DirectionProvider`. |
## Trigger → Menu.Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | See cross-cutting rules. When rendering a non-button, also set `nativeButton={false}`. |
## Portal → Menu.Portal
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `forceMount` | `boolean` / – | `keepMounted` (`boolean`, `false`) | Renamed; keeps portal in DOM while hidden. |
| `container` | `HTMLElement` / `document.body` | `container` (`HTMLElement \| ShadowRoot \| React.RefObject<HTMLElement \| ShadowRoot \| null> \| null`) | Same, wider type (accepts refs and ShadowRoot). |
## Content → Menu.Portal > Menu.Positioner > Menu.Popup
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` on `Popup` | See cross-cutting rules. |
| `loop` | `boolean` / `false` | `loopFocus` on **Root** (`boolean`, `true`) | Moved + renamed. Default flips: Base UI loops by default. |
| `onCloseAutoFocus` | `(event: Event) => void` / – | `finalFocus` on **Popup** | Signature changed: `boolean \| React.RefObject<HTMLElement \| null> \| ((closeType: InteractionType) => boolean \| void \| HTMLElement \| null)` where `InteractionType = 'mouse' \| 'touch' \| 'pen' \| 'keyboard'`. Return `false` to replicate `event.preventDefault()`; return an element to redirect focus. |
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` / – | – | Dropped → `onOpenChange` with `reason === 'escape-key'`. |
| `onPointerDownOutside` | `(event: PointerDownOutsideEvent) => void` / – | – | Dropped → `onOpenChange` with `reason === 'outside-press'`. |
| `onFocusOutside` | `(event: FocusOutsideEvent) => void` / – | – | Dropped → `onOpenChange` with `reason === 'focus-out'`. |
| `onInteractOutside` | `(event: PointerDownOutsideEvent \| FocusOutsideEvent) => void` / – | – | Dropped → `onOpenChange` with `reason === 'outside-press' \|\| 'focus-out'`. |
| `forceMount` | `boolean` / – | `keepMounted` on **Portal** | Moved; animate with `data-starting-style`/`data-ending-style`. |
| `side` | `"top" \| "right" \| "bottom" \| "left"` / `"bottom"` | `side` on **Positioner** (`Side`, `'bottom'`) | Moved. Base adds logical values: `Side = 'top' \| 'bottom' \| 'left' \| 'right' \| 'inline-end' \| 'inline-start'`. |
| `sideOffset` | `number` / `0` | `sideOffset` on **Positioner** (`number \| OffsetFunction`, `0`) | Moved; also accepts `(data: { side, align, anchor: {width,height}, positioner: {width,height} }) => number`. |
| `align` | `"start" \| "center" \| "end"` / `"center"` | `align` on **Positioner** (`Align`, `'center'`) | Moved, same values/default. |
| `alignOffset` | `number` / `0` | `alignOffset` on **Positioner** (`number \| OffsetFunction`, `0`) | Moved; also accepts function form. |
| `avoidCollisions` | `boolean` / `true` | `collisionAvoidance` on **Positioner** (`CollisionAvoidance`) | Signature changed: object `{ side?: 'flip' \| 'shift' \| 'none'; align?: 'flip' \| 'shift' \| 'none'; fallbackAxisSide?: 'start' \| 'end' \| 'none' }`. `avoidCollisions={false}` → `collisionAvoidance={{ side: 'none', align: 'none', fallbackAxisSide: 'none' }}`. |
| `collisionBoundary` | `Element \| null \| Array<Element \| null>` / `[]` | `collisionBoundary` on **Positioner** (`Boundary`, `'clipping-ancestors'`) | Default changes: Radix `[]` means viewport/clipping ancestors; Base default `'clipping-ancestors'` is equivalent. Also accepts an element or rect. |
| `collisionPadding` | `number \| Padding` / `0` | `collisionPadding` on **Positioner** (`Padding`, `5`) | Same shape; default changes 0 → 5. |
| `arrowPadding` | `number` / `0` | `arrowPadding` on **Positioner** (`number`, `5`) | Same; default changes 0 → 5. |
| `sticky` | `"partial" \| "always"` / `"partial"` | – (see note) | Different concept. Radix `sticky` controls align-axis sticking; closest Base knob is `collisionAvoidance.align` (`'shift'` ≈ partial). Base UI's own `sticky` (`boolean`, `false`) instead keeps the popup in the viewport after the anchor scrolls away, which has no Radix equivalent. |
| `hideWhenDetached` | `boolean` / `false` | – | Dropped as behavior prop. Base always exposes `data-anchor-hidden` on Positioner/Popup; hide via CSS: `[data-anchor-hidden] { visibility: hidden }`. |
## Arrow → Menu.Arrow
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Base Arrow renders a `<div>` you fill with an SVG (Radix renders the svg itself). Place inside `Popup`. |
| `width` | `number` / `10` | – | Dropped; size the child SVG/element with CSS. |
| `height` | `number` / `5` | – | Dropped; size with CSS. |
## Item → Menu.Item
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | For links use `Menu.LinkItem` (renders `<a>`) instead of `render`. |
| `disabled` | `boolean` / – | `disabled` (`boolean`, `false`) | Same. |
| `onSelect` | `(event: Event) => void` / – | `onClick` (`(event: BaseUIEvent<React.MouseEvent<HTMLDivElement>>) => void`) | Renamed + signature changed. `event.preventDefault()` in `onSelect` (keep open) → `closeOnClick={false}` (`boolean`, default `true` on Item). |
| `textValue` | `string` / – | `label` (`string`) | Renamed. |
## Group → Menu.Group
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Same otherwise. |
## Label → Menu.GroupLabel
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Part renamed. Base GroupLabel must be inside a `Group` (it wires `aria-labelledby`); Radix Label could float freely. |
## CheckboxItem → Menu.CheckboxItem
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | – |
| `checked` | `boolean \| 'indeterminate'` / – | `checked` (`boolean`) | `'indeterminate'` dropped. Base adds `defaultChecked` (`boolean`, `false`) for uncontrolled use. |
| `onCheckedChange` | `(checked: boolean) => void` / – | `onCheckedChange` | Signature changed: `(checked: boolean, eventDetails: Menu.CheckboxItem.ChangeEventDetails) => void`. |
| `disabled` | `boolean` / – | `disabled` (`boolean`, `false`) | Same. |
| `onSelect` | `(event: Event) => void` / – | `onClick` + `closeOnClick` | Behavior default flips: Radix closes on select (unless prevented); Base `closeOnClick` defaults to `false` on CheckboxItem. Set `closeOnClick` explicitly to preserve Radix behavior. |
| `textValue` | `string` / – | `label` | Renamed. |
## RadioGroup → Menu.RadioGroup
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | – |
| `value` | `string` / – | `value` (`any`) | Type widens to `any`. Base adds `defaultValue` (`any`) and `disabled` (`boolean`, `false`). |
| `onValueChange` | `(value: string) => void` / – | `onValueChange` | Signature changed: `(value: any, eventDetails: Menu.RadioGroup.ChangeEventDetails) => void`. |
## RadioItem → Menu.RadioItem
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | – |
| `value`* | `string` / – | `value`* (`any`) | Same (required); type widens. |
| `disabled` | `boolean` / – | `disabled` (`boolean`, `false`) | Same. |
| `onSelect` | `(event: Event) => void` / – | `onClick` + `closeOnClick` | `closeOnClick` defaults to `false` on RadioItem (Radix closed by default). |
| `textValue` | `string` / – | `label` | Renamed. |
## ItemIndicator → Menu.CheckboxItemIndicator / Menu.RadioItemIndicator
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Part splits: use the indicator matching the parent item type. Renders `<span>`. |
| `forceMount` | `boolean` / – | `keepMounted` (`boolean`, `false`) | Renamed. |
## Separator → Menu.Separator
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Base adds `orientation` (`'horizontal' \| 'vertical'`, `'horizontal'`). |
## Sub → Menu.SubmenuRoot
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` / – | `defaultOpen` (`boolean`, `false`) | Same. |
| `open` | `boolean` / – | `open` (`boolean`) | Same. |
| `onOpenChange` | `(open: boolean) => void` / – | `onOpenChange` | Signature changed: `(open: boolean, eventDetails: Menu.SubmenuRoot.ChangeEventDetails) => void` (same reason union as Root). |
## SubTrigger → Menu.SubmenuTrigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Renders `<div>`; `nativeButton` defaults `false` here. |
| `disabled` | `boolean` / – | `disabled` (`boolean`, `false`) | Same. |
| `textValue` | `string` / – | `label` | Renamed. Base adds `openOnHover` / `delay` (`100`) / `closeDelay` (`0`) and `onClick`. |
## SubContent → Menu.Portal > Menu.Positioner > Menu.Popup (inside SubmenuRoot)
Same prop fates as **Content** above; Radix-specific defaults to be aware of: `align` default is `"start"` on SubContent (Base Positioner default is `'center'` — set `align="start"` explicitly if you relied on the Radix default; in practice submenu popups anchor to the trigger item and our wrappers set this). Radix SubContent has no `side` prop (side is implied); Base Positioner accepts `side` (use `'inline-end'` for RTL-aware submenus). All outside/escape callbacks, `forceMount`, `loop`, collision props map identically to Content.
## Base UI only props worth knowing (Menu)
- `Root`: `highlightItemOnHover` (`true`), `actionsRef` (`{ unmount(), close() }`), `onOpenChangeComplete(open)` (fires after close animation; replaces the Radix "wait for animation" dance), `closeParentOnEsc` (`false`), `disabled`, `orientation` (`'vertical'`), detached-trigger machinery: `handle` (`Menu.Handle` via `Menu.createHandle()`), `triggerId`/`defaultTriggerId`, payload-aware `children` render function.
- `Trigger`: `openOnHover`, `delay` (`100`), `closeDelay` (`0`), `payload`, `handle`, `nativeButton` (`true`).
- `Backdrop`: new part, overlay under the popup.
- `Positioner`: `anchor`, `positionMethod` (`'absolute'`), `disableAnchorTracking`, `sticky` (boolean, viewport-keeping).
- `Popup`: `finalFocus`.
- `Viewport`: new part for animating content swaps with multiple/detached triggers.
- `LinkItem`: new part, `<a>`-rendering menu item (`closeOnClick` default `false`).
- All parts: `className`/`style` accept state-callback form `(state) => ...`.
## Data-attribute mapping (dropdown/context/menubar menus)
| Radix | Base UI |
| --- | --- |
| Trigger `[data-state="open" \| "closed"]` | `data-popup-open` (presence) + `data-pressed` |
| Content `[data-state="open" \| "closed"]` | `data-open` / `data-closed` on Positioner and Popup |
| – | `data-starting-style` / `data-ending-style` (CSS transition hooks, replace animating on `data-state`) |
| Content `[data-side="left" \| "right" \| "bottom" \| "top"]` | `data-side` (`'top' \| 'bottom' \| 'left' \| 'right' \| 'inline-end' \| 'inline-start'`) on Positioner/Popup/Arrow |
| Content `[data-align="start" \| "end" \| "center"]` | `data-align` (same values) on Positioner/Popup/Arrow |
| Content/Item `[data-orientation]` | Dropped on menu parts |
| Item `[data-highlighted]` | `data-highlighted` (same) |
| Item `[data-disabled]` | `data-disabled` (same) |
| Checkbox/RadioItem `[data-state="checked" \| "unchecked" \| "indeterminate"]` | `data-checked` / `data-unchecked` presence attrs; no indeterminate |
| ItemIndicator `[data-state]` | `data-checked` / `data-unchecked` + `data-starting-style` / `data-ending-style` on the split indicators |
| SubTrigger `[data-state="open" \| "closed"]` | `data-popup-open` on SubmenuTrigger |
| – | Popup `data-instant` (`'click' \| 'dismiss' \| 'group' \| 'trigger-change'`), Positioner `data-anchor-hidden` |
## CSS variable mapping (per menu flavor: `dropdown-menu` / `context-menu` / `menubar`)
| Radix (on Content/SubContent) | Base UI (on Positioner) |
| --- | --- |
| `--radix-<name>-content-transform-origin` | `--transform-origin` |
| `--radix-<name>-content-available-width` | `--available-width` |
| `--radix-<name>-content-available-height` | `--available-height` |
| `--radix-<name>-trigger-width` | `--anchor-width` |
| `--radix-<name>-trigger-height` | `--anchor-height` |
Base UI Menu.Viewport additionally exposes `--popup-width` / `--popup-height` (previous-content dimensions during transitions).
---
# context-menu (Radix `ContextMenu` → Base UI `ContextMenu`)
Base UI ContextMenu shares the Menu part set: `Root, Trigger, Portal, Backdrop, Positioner, Popup, Arrow, Item, Group, GroupLabel, Separator, SubmenuRoot, SubmenuTrigger, RadioGroup, RadioItem, RadioItemIndicator, CheckboxItem, CheckboxItemIndicator, LinkItem`. Everything not listed below maps exactly as in the dropdown-menu section.
## Root → ContextMenu.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `dir` | `"ltr" \| "rtl"` / – | – | Dropped; `DirectionProvider`. |
| `open` | `boolean` / – | `open` (`boolean`) | Same. Base also adds `defaultOpen` (`false`), which Radix ContextMenu lacked. |
| `onOpenChange` | `(open: boolean) => void` / – | `onOpenChange` | Signature changed: `(open: boolean, eventDetails: ContextMenu.Root.ChangeEventDetails) => void` (same reason union as Menu). |
| `modal` | `boolean` / `true` | – | **Dropped.** Base UI ContextMenu.Root has no `modal` prop (behavior is fixed). If you relied on `modal={false}`, there is no direct equivalent. |
## Trigger → ContextMenu.Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Base Trigger renders a `<div>` (also handles long-press on touch). |
| `disabled` | `boolean` / `false` | – | **Dropped.** ContextMenu.Trigger has only `className`/`style`/`render`. Workaround: conditionally render content outside the Trigger, or intercept `onContextMenu` with `preventDefault` + `stopPropagation` on a child. |
## Portal → ContextMenu.Portal
Identical mapping to Menu.Portal (`forceMount` → `keepMounted`, `container` widened).
## Content → ContextMenu.Portal > Positioner > Popup
Same fates as dropdown-menu Content for: `asChild`, `loop` (→ Root `loopFocus`), `onCloseAutoFocus` (→ Popup `finalFocus`), `onEscapeKeyDown`/`onPointerDownOutside`/`onFocusOutside`/`onInteractOutside` (→ Root `onOpenChange` reasons), `forceMount` (→ Portal `keepMounted`), `avoidCollisions` (→ `collisionAvoidance`), `collisionBoundary` (default `[]` → `'clipping-ancestors'`), `collisionPadding` (`0` → `5`), `sticky` (dropped, see menu note), `hideWhenDetached` (→ CSS on `data-anchor-hidden`).
Radix-specific deltas:
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `alignOffset` | `number` / `0` | `alignOffset` on Positioner (`number \| OffsetFunction`, `0`) | Radix ContextMenu.Content has no `side`/`sideOffset`/`align` props (anchored to pointer). Base Positioner still accepts `side`/`align`/`sideOffset` but anchors to the pointer position by default; usually leave them off. |
| (no `arrowPadding` on Content) | – | `arrowPadding` (`5`) on Positioner | Available in Base if you add an Arrow. |
## Arrow / Item / Group / Label / CheckboxItem / RadioGroup / RadioItem / ItemIndicator / Separator / Sub / SubTrigger / SubContent
Identical fates to the dropdown-menu section (Label → `GroupLabel`, ItemIndicator → `CheckboxItemIndicator`/`RadioItemIndicator`, Sub → `SubmenuRoot`, SubTrigger → `SubmenuTrigger`, SubContent → `Portal > Positioner > Popup`). Menubar/ContextMenu submenus in Base are the same components with the same props (`onOpenChange` eventDetails, `label`, `onClick`/`closeOnClick`, `keepMounted`).
## Base UI only, data attributes, CSS variables
Same as the Menu lists above; ContextMenu.Root additionally supports `handle` (`MenuHandle<unknown>`), `triggerId`/`defaultTriggerId`, `actionsRef`, `onOpenChangeComplete`, `highlightItemOnHover`, `closeParentOnEsc`, `disabled`, `orientation`. Trigger data attributes: `data-popup-open`, `data-pressed` (replacing Radix Trigger `[data-state]`). CSS vars: `--radix-context-menu-*` → `--transform-origin`/`--available-*`/`--anchor-*` on Positioner.
---
# menubar (Radix `Menubar` → Base UI `Menubar` + `Menu`)
Base UI's menubar module exports a single `<Menubar>` container. Every menu inside it is built from `Menu.*` parts (`Menu.Root`, `Menu.Trigger`, `Menu.Portal`, `Menu.Positioner`, `Menu.Popup`, items, submenus...). So the Radix `Menubar.Menu/Trigger/Portal/Content/...` parts all map to the `Menu` component family from the dropdown-menu section.
## Root → Menubar
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Same pattern. |
| `defaultValue` | `string` / – | – | **Dropped.** Base Menubar has no controlled/uncontrolled active-menu value. To pre-open a menu, use `defaultOpen` on that `Menu.Root`. |
| `value` | `string` / – | – | **Dropped.** Control individual `Menu.Root` `open` props instead. |
| `onValueChange` | `(value: string) => void` / – | – | **Dropped.** Listen via each `Menu.Root` `onOpenChange`. |
| `dir` | `"ltr" \| "rtl"` / – | – | Dropped; `DirectionProvider`. |
| `loop` | `boolean` / `false` | `loopFocus` (`boolean`, `true`) | Renamed; default flips to `true`. |
Base UI only on `Menubar`: `modal` (`boolean`, `true`), `disabled` (`boolean`, `false`), `orientation` (`'horizontal' \| 'vertical'`, `'horizontal'`).
## Menu → Menu.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | – | Menu.Root renders no element; drop it. |
| `value` | `string` / – | – | Dropped with the Menubar value system (see Root). |
Note: `Menu.Root` inside a Menubar accepts all Menu.Root props (`open`, `defaultOpen`, `onOpenChange(open, eventDetails)`, `modal`, `loopFocus`, `orientation`, `disabled`, ...). Hover-switching between menubar menus is built in.
## Trigger → Menu.Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` (+ `nativeButton={false}` for non-buttons) | Radix Menubar.Trigger data attrs `[data-state]`/`[data-highlighted]`/`[data-disabled]` → `data-popup-open`/`data-pressed` (no highlighted state on Base trigger). |
## Portal / Content / Arrow / Item / Group / Label / CheckboxItem / RadioGroup / RadioItem / ItemIndicator / Separator / Sub / SubTrigger / SubContent
All identical to the **dropdown-menu** section (they are literally the same Base UI `Menu` components):
- `Portal.forceMount` → `keepMounted`; `container` widened.
- `Content` (`loop`, `onCloseAutoFocus`, outside/escape callbacks, `forceMount`, `side`/`sideOffset`/`align`/`alignOffset`, `avoidCollisions`, `collisionBoundary`, `collisionPadding`, `arrowPadding`, `sticky`, `hideWhenDetached`) → `Menu.Portal > Menu.Positioner > Menu.Popup` with the exact fates listed for dropdown-menu Content.
- `SubContent` `align` default `"start"` note applies as in dropdown-menu.
- Items: `onSelect` → `onClick` + `closeOnClick`, `textValue` → `label`.
- Radix Menubar CheckboxItem/RadioItem `[data-state="checked" \| "unchecked"]` → `data-checked`/`data-unchecked`.
## Data attributes / CSS variables
- Menubar container: Radix Root had none; Base `Menubar` exposes `data-orientation` (`'horizontal' \| 'vertical'`), `data-has-submenu-open`, `data-modal`.
- Menu-part attributes and `--radix-menubar-*` CSS vars map exactly as in the dropdown-menu tables (`--transform-origin`, `--available-width/height`, `--anchor-width/height` on `Menu.Positioner`).
---
# navigation-menu (Radix `NavigationMenu` → Base UI `NavigationMenu`)
Base UI parts: `Root, List, Item, Trigger, Icon, Content, Portal, Backdrop, Positioner, Popup, Arrow, Viewport, Link`. Popup positioning is real anchored positioning (like Menu): the shared popup renders as `Portal > Positioner > Popup > Viewport`, and each `Item`'s `Content` is moved into the `Viewport` when active. Radix's "Viewport rendered below the list" model is replaced by this anchored Positioner model (our wrappers removed the `viewport` boolean prop accordingly).
## Root → NavigationMenu.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultValue` | `string` / – | `defaultValue` (`Value \| null`, `null`) | Type widens (`Value = any`); `null` = closed. |
| `value` | `string` / – | `value` (`Value \| null`, `null`) | Same; non-nullish = open. |
| `onValueChange` | `(value: string) => void` / – | `onValueChange` | Signature changed: `(value: Value \| null, eventDetails: NavigationMenu.Root.ChangeEventDetails) => void`; reasons: `'trigger-press' \| 'trigger-hover' \| 'outside-press' \| 'list-navigation' \| 'focus-out' \| 'escape-key' \| 'link-press' \| 'none'`. |
| `delayDuration` | `number` / `200` | `delay` (`number`, `50`) | Renamed; default 200 → 50. |
| `skipDelayDuration` | `number` / `300` | – | **Dropped.** No skip-delay window; Base instead has `closeDelay` (`number`, `50`). |
| `dir` | `"ltr" \| "rtl"` / – | – | Dropped; `DirectionProvider`. |
| `orientation` | `"horizontal" \| "vertical"` / `"horizontal"` | `orientation` (same values/default) | Same. |
Base UI only on Root: `closeDelay` (`50`), `actionsRef` (`{ unmount() }`), `onOpenChangeComplete(open)`. Root renders a `<nav>` element (Radix Root also rendered `<nav>`; Base renders `<div>` when nested).
## Sub → nested NavigationMenu.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultValue` / `value` / `onValueChange` / `orientation` | as Root | same props on the nested `Root` | Part dropped: nest a whole `NavigationMenu.Root` (with its own `List`/`Portal`/`Positioner`/`Popup`) inside a `Content`; it renders a `<div>` when nested. Unlike Radix Sub, a nested Base menu is closed by default (`null`), not required to always have an active item. |
## List → NavigationMenu.List
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Base List renders `<ul>` (Radix also `<ul>`). Radix `[data-orientation]` attr dropped. |
## Item → NavigationMenu.Item
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Base Item renders `<li>`. |
| `value` | `string` / – | `value` (`any`) | Same; auto-generated if omitted. |
## Trigger → NavigationMenu.Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` (+ `nativeButton`, default `true`) | `[data-state="open" \| "closed"]` → `data-popup-open`; `[data-disabled]` dropped (no disabled prop either — gate at the item level yourself). |
## Content → NavigationMenu.Content
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | – |
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` / – | – | Dropped → Root `onValueChange` with `reason === 'escape-key'` + `eventDetails.cancel()`. |
| `onPointerDownOutside` | `(event: PointerDownOutsideEvent) => void` / – | – | Dropped → `reason === 'outside-press'`. |
| `onFocusOutside` | `(event: FocusOutsideEvent) => void` / – | – | Dropped → `reason === 'focus-out'`. |
| `onInteractOutside` | `(event: PointerDownOutsideEvent \| FocusOutsideEvent) => void` / – | – | Dropped → `'outside-press' \| 'focus-out'`. |
| `forceMount` | `boolean` / – | `keepMounted` (`boolean`, `false`) | Renamed, stays on Content (keeps content in DOM while closed, e.g. for SEO/SSR). |
## Link → NavigationMenu.Link
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | For framework links: `render={<NextLink href=... />}`. |
| `active` | `boolean` / `false` | `active` (`boolean`, `false`) | Same (sets `aria-current` + `data-active`). |
| `onSelect` | `(event: Event) => void` / – | – | Dropped. Use `onClick` (plain DOM prop) and `closeOnClick` (`boolean`, `false`) — note Radix closed the menu on link select by default, Base does not; set `closeOnClick` for parity. |
## Indicator → NavigationMenu.Icon (per wrapper ground truth)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Different role: Radix Indicator tracked the active trigger below the List; Base `Icon` is a chevron inside the Trigger (`data-popup-open` when its menu is open). For a popup-anchored pointer, Base's `Arrow` (inside `Popup`, with `data-side`/`data-align`/`data-uncentered`) is the closest visual analogue. There is no Base part that tracks the active trigger along the list. |
| `forceMount` | `boolean` / – | – | Dropped (Icon is always rendered). |
| `[data-state="visible" \| "hidden"]`, `[data-orientation]` | – | – | Dropped; Icon exposes only `data-popup-open`. |
## Viewport → NavigationMenu.Portal > Positioner > Popup > Viewport
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` on each new part | One Radix part becomes four: `Portal` (props: `container`, `keepMounted`), `Positioner` (full anchored-positioning prop set identical to Menu.Positioner: `side`/`sideOffset`/`align`/`alignOffset` (`number \| OffsetFunction`), `anchor`, `collisionAvoidance`, `collisionBoundary` `'clipping-ancestors'`, `collisionPadding` `5`, `arrowPadding` `5`, `sticky` boolean, `positionMethod`, `disableAnchorTracking`), `Popup` (renders `<nav>`), `Viewport` (clips/animates the active `Content`). |
| `forceMount` | `boolean` / – | `keepMounted` on **Portal** | Renamed + moved. |
## Base UI only props worth knowing (NavigationMenu)
- Root: `closeDelay`, `actionsRef`, `onOpenChangeComplete`.
- New parts: `Backdrop`, `Arrow`, `Positioner` (real collision-aware positioning — Radix nav-menu had none), `Icon`.
- `Content.keepMounted` for crawler-visible SSR content.
- `Link.closeOnClick`.
- All parts accept `className`/`style` state-callback forms and `render`.
## Data-attribute mapping (navigation-menu)
| Radix | Base UI |
| --- | --- |
| Root/Sub/List/Item `[data-orientation]` | Dropped. |
| Trigger `[data-state="open" \| "closed"]` | `data-popup-open` on Trigger (and on Icon). |
| Trigger `[data-disabled]` | Dropped. |
| Content `[data-state="open" \| "closed"]` | `data-open` / `data-closed` on Content (also on Positioner/Popup/Backdrop). |
| Content `[data-motion="to-start" \| "to-end" \| "from-start" \| "from-end"]` | `data-activation-direction` (`'left' \| 'right' \| 'up' \| 'down'`) on Content — direction the newly-activated trigger is relative to the previous one; use for enter/exit animations. |
| Link `[data-active]` | `data-active` (same). |
| Indicator `[data-state="visible" \| "hidden"]` | No equivalent (see Indicator row). Arrow exposes `data-open`/`data-closed`/`data-uncentered`/`data-side`/`data-align`. |
| Viewport `[data-state]`, `[data-orientation]` | `data-open`/`data-closed` + `data-starting-style`/`data-ending-style` on Popup/Positioner; Viewport itself exposes none. |
| – | Positioner: `data-anchor-hidden`, `data-instant`; Popup: `data-side`, `data-align`. |
## CSS variable mapping (navigation-menu)
| Radix | Base UI |
| --- | --- |
| `--radix-navigation-menu-viewport-width` (on Viewport) | `--popup-width` (`number`, on **Popup**) — fixed width of the popup; animate `width: var(--popup-width)`. |
| `--radix-navigation-menu-viewport-height` (on Viewport) | `--popup-height` (`number`, on **Popup**). |
| – | Positioner also exposes `--anchor-width`, `--anchor-height`, `--available-width`, `--available-height`, `--positioner-width`, `--positioner-height`, `--transform-origin`. |
---
## Gaps / caveats
- Radix prop descriptions are rendered in JS popovers; types/defaults above were extracted from the pages' embedded type payloads (`(open: boolean) => void`, `(checked: boolean) => void`, `(value: string) => void`, `(event: KeyboardEvent) => void`, `(event: PointerDownOutsideEvent) => void`, `(event: FocusOutsideEvent) => void`, `(event: PointerDownOutsideEvent | FocusOutsideEvent) => void`, `(event: Event) => void` for `onSelect`/`onCloseAutoFocus`, `Boundary = Element | null | Array<Element | null>`, `sticky: "partial" | "always"`, `dir: "ltr" | "rtl"`) — all verified against the fetched HTML.
- Base UI ContextMenu.Root genuinely lacks `modal`; Menubar lacks the value/onValueChange system; ContextMenu.Trigger lacks `disabled`. These are the three hard drops with no one-line workaround.
- Base UI docs fetched from the `.md` endpoints reflect Base UI 1.x (repo currently pins 1.6.0).
overlays.md›
# Radix 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?: boolean` merges props onto the single child. Base UI: `render?: ReactElement | ((props: HTMLProps, state: Part.State) => ReactElement)`. For buttons replaced with non-button elements, also set `nativeButton={false}`.
- `onOpenChange` signature changed everywhere. Radix: `(open: boolean) => void`. Base UI: `(open: boolean, eventDetails: X.Root.ChangeEventDetails) => void` where `eventDetails` is `{ 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 by `onOpenChange`'s `eventDetails.reason` (`'escape-key'`, `'outside-press'`, `'focus-out'`) + `eventDetails.cancel()` to prevent the close (the equivalent of Radix `event.preventDefault()`).
- `forceMount` (Radix, Portal/Overlay/Content) → `keepMounted` on Base UI `Portal` only (`boolean`, default `false`). For exit animations Base UI does not need it: it holds the popup mounted itself and exposes `data-starting-style` / `data-ending-style`, `onOpenChangeComplete`, and `actionsRef.current.unmount()` for externally-controlled animations.
- Base UI `className` and `style` accept state callbacks (`(state) => ...`) on every rendered part.
- Radix `[data-state="open" | "closed"]` → Base UI presence attributes `data-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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` / - (Base default `false`) | `defaultOpen` | Same. |
| `open` | `boolean` / - | `open` | Same. |
| `onOpenChange` | `(open: boolean) => void` / - | `onOpenChange` | Signature changed: `(open: boolean, eventDetails: Dialog.Root.ChangeEventDetails) => void`. Reasons: `'trigger-press' \| 'outside-press' \| 'escape-key' \| 'close-press' \| 'focus-out' \| 'imperative-action' \| 'none'`. |
| `modal` | `boolean` / `true` | `modal` | Widened: `boolean \| 'trap-focus'`, default `true`. `'trap-focus'` traps focus without scroll lock / outside-pointer blocking. |
## Trigger → Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | `render={<MyButton />}`; add `nativeButton={false}` if the rendered element is not a `<button>`. |
## Portal → Portal
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `forceMount` | `boolean` / - | `keepMounted` | Renamed + inverted intent: `keepMounted?: boolean` (default `false`) keeps the portal in the DOM while hidden. Usually droppable; Base UI keeps the popup mounted during exit animations automatically. |
| `container` | `HTMLElement` / `document.body` | `container` | Same name, wider type: `HTMLElement \| ShadowRoot \| React.RefObject<HTMLElement \| ShadowRoot \| null> \| null`. Note: Base UI Portal renders a `<div>` wrapper (Radix Portal renders nothing extra per child). |
## Overlay → Backdrop
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Same pattern. |
| `forceMount` | `boolean` / - | dropped | Backdrop stays mounted through exit animations natively; use `Portal keepMounted` if you need always-mounted DOM. Base-only: `forceRender` (`boolean`, default `false`) forces the backdrop to render even when the dialog is nested. |
## Content → Popup
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Same pattern. |
| `forceMount` | `boolean` / - | dropped | See Portal `keepMounted` + `Root actionsRef.unmount()` + `onOpenChangeComplete`. |
| `onOpenAutoFocus` | `(event: Event) => void` / - | moved to Popup `initialFocus` | Signature changed. Radix: prevent via `event.preventDefault()`. Base: `initialFocus?: boolean \| RefObject<HTMLElement \| null> \| ((openType: InteractionType) => boolean \| void \| HTMLElement \| null)`. `false` = don't move focus; ref/element = focus target; function receives `'mouse' \| 'touch' \| 'pen' \| 'keyboard'`. |
| `onCloseAutoFocus` | `(event: Event) => void` / - | moved to Popup `finalFocus` | Same shape as `initialFocus` but for close (`closeType: InteractionType`). |
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` / - | moved to Root `onOpenChange` | `if (eventDetails.reason === 'escape-key') eventDetails.cancel()` replaces `event.preventDefault()`. |
| `onPointerDownOutside` | `(event: PointerDownOutsideEvent) => void` / - | moved to Root `onOpenChange` | Reason `'outside-press'`; cancel with `eventDetails.cancel()`. Declarative shortcut: Root `disablePointerDismissal` (`boolean`, default `false`). |
| `onInteractOutside` | `(event: PointerDownOutsideEvent \| FocusOutsideEvent) => void` / - | moved to Root `onOpenChange` | Covers reasons `'outside-press'` and `'focus-out'` (focus-out applies to non-modal dialogs). |
## Title → Title / Description → Description / Close → Close
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` (Title) | `boolean` / `false` | `render` | Base Title renders `<h2>`. |
| `asChild` (Description) | `boolean` / `false` | `render` | Base Description renders `<p>`. |
| `asChild` (Close) | `boolean` / `false` | `render` | Base Close renders `<button>`; `nativeButton` available. |
## 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), `children` as 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)
| Radix | Base UI | Where |
| --- | --- | --- |
| `[data-state="open"]` | `data-open` | Backdrop, Popup, Viewport (presence attr). |
| `[data-state="closed"]` | `data-closed` | Backdrop, Popup, Viewport. |
| `[data-state]` on Trigger | `data-popup-open` | Trigger (presence attr). |
| - | `data-disabled` | Trigger, Close. |
| - | `data-starting-style` / `data-ending-style` | Backdrop, Popup, Viewport; hooks for enter/exit CSS transitions (replaces Radix animate-on-`data-state` idiom). |
| - | `data-nested`, `data-nested-dialog-open` | Popup, Viewport. |
## CSS variables (dialog)
| Radix | Base UI |
| --- | --- |
| (none documented) | `--nested-dialogs` (`number`, on Popup): count of dialogs nested within. |
---
# 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 prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` / - (Base default `false`) | `defaultOpen` | Same. |
| `open` | `boolean` / - | `open` | Same. |
| `onOpenChange` | `(open: boolean) => void` / - | `onOpenChange` | Signature changed: `(open: boolean, eventDetails: AlertDialog.Root.ChangeEventDetails) => void`. Same reason union as dialog. |
(Radix AlertDialog.Root has no `modal` prop; Base UI AlertDialog.Root also has none. Parity.)
## Trigger → Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Plus `nativeButton`, `payload`, `handle`, `id`. |
## Portal → Portal
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `forceMount` | `boolean` / - | `keepMounted` | Same as dialog. |
| `container` | `HTMLElement` / `document.body` | `container` | Same as dialog (wider type, renders a `<div>`). |
## Overlay → Backdrop
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Same pattern. |
| `forceMount` | `boolean` / - | dropped | Base-only `forceRender` exists for nested cases. |
## Content → Popup
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Same pattern. |
| `forceMount` | `boolean` / - | dropped | See dialog notes. |
| `onOpenAutoFocus` | `(event: Event) => void` / - | moved to Popup `initialFocus` | Same semantics as dialog. Note: Radix alert-dialog focuses `Cancel` by default; Base UI focuses the first tabbable element. To preserve Radix behavior pass `initialFocus={cancelRef}`. |
| `onCloseAutoFocus` | `(event: Event) => void` / - | moved to Popup `finalFocus` | Same as dialog. |
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` / - | moved to Root `onOpenChange` | Reason `'escape-key'` + `eventDetails.cancel()`. |
(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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` on `AlertDialog.Close` | Renamed part. `nativeButton` available. Radix's "Cancel receives focus on open" default must be recreated with Popup `initialFocus`. |
## Action → (no primitive)
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | dropped | No Base UI part. Wrapper renders a styled `<button>`; close after the action via controlled `open`, `actionsRef.current.close()`, or by composing `AlertDialog.Close` and running the action in `onClick`. |
## Base UI only props worth knowing (alert-dialog)
- Root: `actionsRef`, `onOpenChangeComplete`, `handle` / `triggerId` / `defaultTriggerId` + `AlertDialog.createHandle()`, payload-render `children`.
- 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)
| Radix | Base UI |
| --- | --- |
| (none documented) | `--nested-dialogs` (`number`, on Popup). |
---
# 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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` / - (Base default `false`) | `defaultOpen` | Same. |
| `open` | `boolean` / - | `open` | Same. |
| `onOpenChange` | `(open: boolean) => void` / - | `onOpenChange` | Signature changed: `(open: boolean, eventDetails: Popover.Root.ChangeEventDetails) => void`. Reasons add hover/focus: `'trigger-hover' \| 'trigger-focus' \| 'trigger-press' \| 'outside-press' \| 'escape-key' \| 'close-press' \| 'focus-out' \| 'imperative-action' \| 'none'`. |
| `modal` | `boolean` / `false` | `modal` | Widened: `boolean \| 'trap-focus'`, default `false`. When `true`, focus trapping requires a `Popover.Close` inside the Popup (can be `sr-only`). |
## Trigger → Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Plus `nativeButton`. Base-only on Trigger: `openOnHover` (`false`), `delay` (`300`), `closeDelay` (`0`), `payload`, `handle`, `id`. |
## Anchor → Positioner `anchor` prop
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | dropped (part removed) | No Anchor part. Pass `anchor` to Positioner: `Element \| VirtualElement \| React.RefObject<Element \| null> \| (() => Element \| VirtualElement \| null) \| null`. Default anchor is the trigger. |
## Portal → Portal
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `forceMount` | `boolean` / - | `keepMounted` | `boolean`, default `false`. |
| `container` | `HTMLElement` / `document.body` | `container` | Wider type (adds ShadowRoot/RefObject); Portal renders a `<div>`. |
## Content → Positioner + Popup
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` (Popup) | Same pattern. |
| `onOpenAutoFocus` | `(event: Event) => void` / - | moved to Popup `initialFocus` | Same shape as dialog (`boolean \| RefObject \| (openType: InteractionType) => ...`). |
| `onCloseAutoFocus` | `(event: Event) => void` / - | moved to Popup `finalFocus` | Same shape. |
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` / - | moved to Root `onOpenChange` | Reason `'escape-key'` + `eventDetails.cancel()`. |
| `onPointerDownOutside` | `(event: PointerDownOutsideEvent) => void` / - | moved to Root `onOpenChange` | Reason `'outside-press'` + `eventDetails.cancel()`. |
| `onFocusOutside` | `(event: FocusOutsideEvent) => void` / - | moved to Root `onOpenChange` | Reason `'focus-out'` + `eventDetails.cancel()`. |
| `onInteractOutside` | `(event: PointerDownOutsideEvent \| FocusOutsideEvent) => void` / - | moved to Root `onOpenChange` | Handle both `'outside-press'` and `'focus-out'`. |
| `forceMount` | `boolean` / - | dropped | Use Portal `keepMounted`, `onOpenChangeComplete`, `actionsRef.unmount()`. |
| `side` | `"top" \| "right" \| "bottom" \| "left"` / `"bottom"` | moved to Positioner `side` | Type is `Side` which adds logical values `'inline-start' \| 'inline-end'`. Default `'bottom'` (same). |
| `sideOffset` | `number` / `0` | moved to Positioner `sideOffset` | Widened: `number \| OffsetFunction` where the function receives `{ anchor, positioner, side, align }`. Default `0` (same). |
| `align` | `"start" \| "center" \| "end"` / `"center"` | moved to Positioner `align` | Same values/default. |
| `alignOffset` | `number` / `0` | moved to Positioner `alignOffset` | Widened: `number \| OffsetFunction`. Default `0` (same). |
| `avoidCollisions` | `boolean` / `true` | moved to Positioner `collisionAvoidance` | Signature changed. Radix boolean → Base `CollisionAvoidance` object `{ side?: 'flip' \| 'shift' \| 'none'; align?: 'flip' \| 'shift' \| 'none'; fallbackAxisSide?: 'start' \| 'end' \| 'none' }`. `avoidCollisions={false}` → `collisionAvoidance={{ side: 'none', align: 'none', fallbackAxisSide: 'none' }}`. |
| `collisionBoundary` | `Boundary (Element \| null \| Array<Element \| null>)` / `[]` | moved to Positioner `collisionBoundary` | Same name; Base `Boundary` defaults to `'clipping-ancestors'` (Radix default = viewport/clipping ancestors via `[]`). |
| `collisionPadding` | `number \| Padding` / `0` | moved to Positioner `collisionPadding` | Same shape; default changes `0` → `5`. |
| `arrowPadding` | `number` / `0` | moved to Positioner `arrowPadding` | Same; default changes `0` → `5`. |
| `sticky` | `"partial" \| "always"` / `"partial"` | dropped (repurposed name) | Radix `sticky` governed alignment-axis sticking; Base equivalent is `collisionAvoidance.align` (`'shift'` ≈ sticky behavior). CAUTION: Base UI Positioner has a `sticky: boolean` (default `false`) prop with a DIFFERENT meaning: keep the popup in the viewport after the anchor scrolls out of view. Do not copy the Radix value across. |
| `hideWhenDetached` | `boolean` / `false` | dropped (with workaround) | Positioner/Popup expose `data-anchor-hidden` when the anchor is hidden; recreate with CSS: `[data-anchor-hidden] { visibility: hidden }` on the Positioner. |
## Close → Close
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Plus `nativeButton`. |
## Arrow → Arrow
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Radix renders an `<svg>`; Base renders a `<div>` — supply your own SVG child. |
| `width` | `number` / `10` | dropped | Size the arrow element/SVG via CSS. |
| `height` | `number` / `5` | dropped | Size via CSS. Arrow must be a child of Popup (inside Positioner). |
## Base UI only props worth knowing (popover)
- Root: `actionsRef`, `onOpenChangeComplete`, `handle` / `triggerId` / `defaultTriggerId` + `Popover.createHandle()`, payload-render `children`, `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)
| Radix (on Content/Trigger/Arrow) | Base UI | Where |
| --- | --- | --- |
| `[data-state="open"/"closed"]` | `data-open` / `data-closed` | Backdrop, Positioner, Popup, Arrow. |
| `[data-state]` on Trigger | `data-popup-open` | Trigger. Base also adds `data-pressed`. |
| `[data-side]` `"left" \| "right" \| "bottom" \| "top"` | `data-side` | Positioner, Popup, Arrow; values extended with `'inline-start' \| 'inline-end'`. |
| `[data-align]` `"start" \| "end" \| "center"` | `data-align` | Positioner, Popup, Arrow. |
| - | `data-starting-style` / `data-ending-style` | Popup, Backdrop (enter/exit animation hooks). |
| - | `data-anchor-hidden` | Positioner (replaces `hideWhenDetached`). |
| - | `data-instant` (`'click' \| 'dismiss' \| 'focus' \| 'trigger-change'`) | Popup. |
| - | `data-uncentered` | Arrow (arrow can't center on anchor). |
## CSS variables (popover)
| Radix (on Content) | Base UI (on Positioner unless noted) |
| --- | --- |
| `--radix-popover-content-transform-origin` | `--transform-origin` |
| `--radix-popover-content-available-width` | `--available-width` |
| `--radix-popover-content-available-height` | `--available-height` |
| `--radix-popover-trigger-width` | `--anchor-width` |
| `--radix-popover-trigger-height` | `--anchor-height` |
| - | `--positioner-width` / `--positioner-height` (Positioner), `--popup-width` / `--popup-height` (Popup, and on Viewport's previous container) |
---
# 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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `delayDuration` | `number` / `700` | `delay` | Renamed. Base has no documented default on Provider (Trigger default is `600`). |
| `skipDelayDuration` | `number` / `300` | `timeout` | Renamed + semantics kept: another tooltip opens instantly if the previous closed within `timeout` ms. Default `300` → `400`. |
| `disableHoverableContent` | `boolean` / - | dropped at Provider; see Root `disableHoverablePopup` | Base UI equivalent exists only per-Root (renamed). |
## Root → Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` / - (Base default `false`) | `defaultOpen` | Same. |
| `open` | `boolean` / - | `open` | Same. |
| `onOpenChange` | `(open: boolean) => void` / - | `onOpenChange` | Signature changed: `(open: boolean, eventDetails: Tooltip.Root.ChangeEventDetails) => void`. Reasons: `'trigger-hover' \| 'trigger-focus' \| 'trigger-press' \| 'outside-press' \| 'escape-key' \| 'disabled' \| 'imperative-action' \| 'none'`. |
| `delayDuration` | `number` / `700` | moved to Trigger `delay` | `number`, default `600`. `closeDelay` (default `0`) is also on Trigger. |
| `disableHoverableContent` | `boolean` / - | `disableHoverablePopup` | Renamed; `boolean`, default `false`. |
## Trigger → Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Base-only on Trigger: `delay` (`600`), `closeDelay` (`0`), `closeOnClick` (`true`), `disabled` (`false`), `payload`, `handle`. |
## Portal → Portal
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `forceMount` | `boolean` / - | `keepMounted` | `boolean`, default `false`. |
| `container` | `HTMLElement` / `document.body` | `container` | Wider type; renders a `<div>`. |
## Content → Positioner + Popup
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` (Popup) | Same pattern. |
| `aria-label` | `string` / - | dropped (plain DOM attr) | Pass `aria-label` straight through to Popup if needed; no special prop. |
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` / - | moved to Root `onOpenChange` | Reason `'escape-key'` + `eventDetails.cancel()`. |
| `onPointerDownOutside` | `(event: PointerDownOutsideEvent) => void` / - | moved to Root `onOpenChange` | Reason `'outside-press'` + `eventDetails.cancel()`. |
| `forceMount` | `boolean` / - | dropped | Portal `keepMounted` / `actionsRef.unmount()`. |
| `side` | enum / `"top"` | moved to Positioner `side` | `Side` (adds `'inline-start' \| 'inline-end'`); default `'top'` (same). |
| `sideOffset` | `number` / `0` | moved to Positioner `sideOffset` | `number \| OffsetFunction`; default `0`. |
| `align` | enum / `"center"` | moved to Positioner `align` | Same values/default. |
| `alignOffset` | `number` / `0` | moved to Positioner `alignOffset` | `number \| OffsetFunction`; default `0`. |
| `avoidCollisions` | `boolean` / `true` | moved to Positioner `collisionAvoidance` | Same conversion as popover (`false` → all-`'none'` object). |
| `collisionBoundary` | `Boundary` / `[]` | moved to Positioner `collisionBoundary` | Base default `'clipping-ancestors'`. |
| `collisionPadding` | `number \| Padding` / `0` | moved to Positioner `collisionPadding` | Default `0` → `5`. |
| `arrowPadding` | `number` / `0` | moved to Positioner `arrowPadding` | Default `0` → `5`. |
| `sticky` | `"partial" \| "always"` / `"partial"` | dropped (repurposed name) | Same caveat as popover: Base `sticky: boolean` means "stay in viewport when anchor scrolls away"; alignment sticking is `collisionAvoidance.align`. |
| `hideWhenDetached` | `boolean` / `false` | dropped (with workaround) | Style `[data-anchor-hidden]` on Positioner. |
## Arrow → Arrow
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Base renders `<div>`; bring your own SVG. |
| `width` | `number` / `10` | dropped | CSS sizing. |
| `height` | `number` / `5` | dropped | CSS sizing. |
## 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-render `children`.
- 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)
| Radix | Base UI | Where |
| --- | --- | --- |
| `[data-state]` `"closed" \| "delayed-open" \| "instant-open"` (Content) | `data-open` / `data-closed` + `data-instant` (`'delay' \| 'dismiss' \| 'focus'`) | Popup, Arrow, Positioner (open/closed). The delayed/instant distinction becomes the `data-instant` value. |
| `[data-state]` (Trigger) | `data-popup-open` | Trigger. Base also adds `data-trigger-disabled`. |
| `[data-side]` | `data-side` | Positioner, Popup, Arrow; adds `'inline-start' \| 'inline-end'`. |
| `[data-align]` | `data-align` | Positioner, Popup, Arrow. |
| - | `data-starting-style` / `data-ending-style` | Popup. |
| - | `data-anchor-hidden` | Positioner. |
| - | `data-uncentered` | Arrow. |
## CSS variables (tooltip)
| Radix (on Content) | Base UI (on Positioner) |
| --- | --- |
| `--radix-tooltip-content-transform-origin` | `--transform-origin` |
| `--radix-tooltip-content-available-width` | `--available-width` |
| `--radix-tooltip-content-available-height` | `--available-height` |
| `--radix-tooltip-trigger-width` | `--anchor-width` |
| `--radix-tooltip-trigger-height` | `--anchor-height` |
| - | `--popup-width` / `--popup-height` (Viewport's previous container). |
---
# 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
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` / - (Base default `false`) | `defaultOpen` | Same. |
| `open` | `boolean` / - | `open` | Same. |
| `onOpenChange` | `(open: boolean) => void` / - | `onOpenChange` | Signature changed: `(open: boolean, eventDetails: PreviewCard.Root.ChangeEventDetails) => void`. Reasons: `'trigger-hover' \| 'trigger-focus' \| 'trigger-press' \| 'outside-press' \| 'escape-key' \| 'imperative-action' \| 'none'`. |
| `openDelay` | `number` / `700` | moved to Trigger `delay` | Renamed + moved; default changes `700` → `600`. |
| `closeDelay` | `number` / `300` | moved to Trigger `closeDelay` | Moved; default `300` (same). |
## Trigger → Trigger
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Base Trigger renders `<a>`; Base-only: `delay` (`600`), `closeDelay` (`300`), `payload`, `handle`. No `nativeButton` (it is a link, not a button). |
## Portal → Portal
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `forceMount` | `boolean` / - | `keepMounted` | `boolean`, default `false`. |
| `container` | `HTMLElement` / `document.body` | `container` | Wider type; renders a `<div>`. |
## Content → Positioner + Popup
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` (Popup) | Same pattern. |
| `forceMount` | `boolean` / - | dropped | Portal `keepMounted` / `actionsRef.unmount()` / `onOpenChangeComplete`. |
| `side` | enum / `"bottom"` | moved to Positioner `side` | `Side` (adds `'inline-start' \| 'inline-end'`); default `'bottom'` (same). |
| `sideOffset` | `number` / `0` | moved to Positioner `sideOffset` | `number \| OffsetFunction`; default `0`. |
| `align` | enum / `"center"` | moved to Positioner `align` | Same values/default. |
| `alignOffset` | `number` / `0` | moved to Positioner `alignOffset` | `number \| OffsetFunction`; default `0`. |
| `avoidCollisions` | `boolean` / `true` | moved to Positioner `collisionAvoidance` | Same conversion as popover. |
| `collisionBoundary` | `Boundary` / `[]` | moved to Positioner `collisionBoundary` | Base default `'clipping-ancestors'`. |
| `collisionPadding` | `number \| Padding` / `0` | moved to Positioner `collisionPadding` | Default `0` → `5`. |
| `arrowPadding` | `number` / `0` | moved to Positioner `arrowPadding` | Default `0` → `5`. |
| `sticky` | `"partial" \| "always"` / `"partial"` | dropped (repurposed name) | Same caveat as popover/tooltip. |
| `hideWhenDetached` | `boolean` / `false` | dropped (with workaround) | Style `[data-anchor-hidden]` on Positioner. |
(Radix HoverCard.Content documents no dismiss callbacks; escape/outside dismissal maps to Root `onOpenChange` reasons `'escape-key'` / `'outside-press'` if needed.)
## Arrow → Arrow
| Radix prop | Type / default | Base UI equivalent | Migration note |
| --- | --- | --- | --- |
| `asChild` | `boolean` / `false` | `render` | Base renders `<div>`. |
| `width` | `number` / `10` | dropped | CSS sizing. |
| `height` | `number` / `5` | dropped | CSS sizing. |
## Base UI only props worth knowing (preview-card)
- Root: `actionsRef`, `onOpenChangeComplete`, `handle` / `triggerId` / `defaultTriggerId` + `PreviewCard.createHandle()`, payload-render `children`.
- Trigger: `payload`, `handle`, per-trigger `delay`/`closeDelay`.
- Positioner: `positionMethod`, `disableAnchorTracking`, `anchor`, `collisionAvoidance`.
- New parts: `Backdrop`, `Viewport`.
## Data attributes (preview-card)
| Radix | Base UI | Where |
| --- | --- | --- |
| `[data-state="open"/"closed"]` | `data-open` / `data-closed` | Backdrop, Positioner, Popup, Arrow. |
| `[data-state]` (Trigger) | `data-popup-open` | Trigger. |
| `[data-side]` | `data-side` | Positioner, Popup, Arrow; adds `'inline-start' \| 'inline-end'`. |
| `[data-align]` | `data-align` | Positioner, Popup, Arrow. |
| - | `data-starting-style` / `data-ending-style` | Popup, Backdrop. |
| - | `data-anchor-hidden` | Positioner. |
| - | `data-uncentered` | Arrow. |
## CSS variables (preview-card)
| Radix (on Content) | Base UI (on Positioner) |
| --- | --- |
| `--radix-hover-card-content-transform-origin` | `--transform-origin` |
| `--radix-hover-card-content-available-width` | `--available-width` |
| `--radix-hover-card-content-available-height` | `--available-height` |
| `--radix-hover-card-trigger-width` | `--anchor-width` |
| `--radix-hover-card-trigger-height` | `--anchor-height` |
| - | `--popup-width` / `--popup-height` (Viewport's previous container). |
SKILL.md›
---
name: migrate-radix-to-base
description: Migrates React projects and components from Radix UI to Base UI. Use when asked to migrate from radix, move to base-ui, convert radix primitives, or switch a shadcn project's base library. Handles single components ("migrate accordion") and whole projects.
---
# Radix UI -> Base UI migration
You migrate shadcn wrappers, hand-rolled radix compositions, and their
consumers to `@base-ui/react`, keeping the project buildable at every step.
Be precise; never guess a mapping. When a prop or part is not in these
reference files, check `node_modules/@base-ui/react/**/*.d.ts` before
transforming, and record gaps in the report.
## Preflight (always)
1. `npx shadcn@latest info --json` (or the project's runner): gives the
current base, STYLE (e.g. `radix-lyra`), tailwind version, aliases,
installed components, and package manager. Trust it over inference.
2. Detect the package manager (packageManager field / lockfile:
pnpm-lock.yaml, bun.lock, yarn.lock, package-lock.json) and use IT for
every install. Never leave a stale lockfile.
3. Require a clean git tree; work on a branch; one commit per component.
4. Baseline check BEFORE touching dependencies: run the project's
typecheck/build so pre-existing failures are never attributed to you.
5. Install `@base-ui/react` alongside radix. Radix packages are removed only
after the LAST component is migrated (both coexist fine).
## Strategy: golden pair first, transformation engine second
- **Golden pair via the CLI (preferred).** If the project is shadcn with a
known style (`radix-<style>`), the shadcn CLI itself is the golden-pair
executor:
1. Classify each ui wrapper FIRST: diff the user's file against its stock
origin, using the components.json style VERBATIM in the URL
(`https://ui.shadcn.com/r/styles/<style>/<component>.json`,
files[0].content). This works for prefixed styles (radix-nova) AND
legacy unprefixed ones (new-york, new-york-v4, default), which are all
still served.
2. WHOLE-PROJECT mode: flip `components.json` style `radix-<style>` ->
`base-<style>` now. PROGRESSIVE mode: do NOT flip yet (the project is
still mostly radix; the flip happens once, after the last component);
fetch base variants directly by URL instead
(`https://ui.shadcn.com/r/styles/base-<style>/<component>.json`).
3. PRISTINE wrappers, whole-project mode: `shadcn add <component>
--overwrite` delivers the base variant with the project's exact
icon/font/preset resolution. Never bulk `--all --overwrite`; go
component by component, or you drown in unrelated registry version
drift. PROGRESSIVE mode: never use `--overwrite` (it destroys the
original that consumers still import); write the fetched base variant
content to `<component>-base.tsx` instead.
4. CUSTOMIZED wrappers: fetch the base variant and replay the user's diff
onto it (their customizations must SURVIVE; `--overwrite` would destroy
them). Mechanical implementation that works at scale:
`git merge-file user.tsx radix-golden.tsx base-golden.tsx` (three-way
merge, radix golden as ancestor) auto-resolves most files; hand-resolve
conflicts with the reference tables.
5. MANDATORY leftover sweep on EVERY golden-pair file, including ones that
merged "clean": `grep -n "radix-ui\|@radix-ui\|IconPlaceholder"` per
file. The registry sometimes reorders functions between variants, which
makes three-way merges report zero conflicts while leaving stale radix
hunks in place. A clean merge is NOT proof of a clean file.
This is more reliable than reconstructing transforms; use it whenever the
pair exists. Consumer/app code has no CLI mechanism: always hand-migrate it
against `consumer-props.md`.
- **Legacy styles (new-york, new-york-v4, default): classification only, no
replay.** These have no base counterpart (there is no base-new-york), and
retargeting onto a base-<style> variant would restyle the user's app. Use
the radix golden ONLY to detect customizations, then run the transformation
engine on the user's OWN file: rewire primitives, keep their exact classes,
apply class-mapping renames. Their look stays theirs. At the end of a
legacy whole-project migration, FLAG (do not fix): the style name still
reads as radix to the CLI, so future `shadcn add` will deliver radix
variants; the user decides whether to switch style or add manually.
- **Transformation engine (fallback).** Hand-rolled radix code, non-shadcn
projects, unknown styles: transform using `universal-patterns.md` (imports
in BOTH forms: `radix-ui` and `@radix-ui/react-*`; asChild->render with the
worked example; Portal>Positioner>Popup; the positioner FORWARD rule; part
renames), the per-family props tables (`overlays.md`, `menus.md`,
`form-controls.md`, `disclosure.md`, `display-misc.md`), `class-mapping.md`
for data-attribute/CSS-var rewrites, and `wrapper-shapes.md` for exact
target shapes (tooltip arrow, SubContent defaults, select anatomy).
## Modes
**Progressive (default).** "Migrate accordion" = one component, strangler-fig:
1. Detect in-progress state first: an existing `<component>-base.tsx`,
consumers split between old/new imports. The files ARE the state; resume,
never restart.
2. If the component imports other ui wrappers still on radix (select ->
button), STOP and recommend migrating those first, bottom-up.
3. Write the migrated version to `<component>-base.tsx` (original untouched;
golden-pair content fetched by URL, or transformed by hand, per the
strategy above); typecheck. Repoint consumers ONE AT A TIME (imports + the
call-site props in `consumer-props.md`); typecheck each. When no consumer
imports the original: delete it, rename `-base` -> original, flip imports
back, final check, commit. When the LAST radix wrapper in the project is
finalized, flip `components.json` to `base-<style>` and remove radix deps.
**Whole project** (only when explicitly asked): same per-component work in
dependency order (leaf/shared wrappers like button and label first). After
wrappers, sweep ALL app code against `consumer-props.md` — the call-site
break surface is much larger than asChild. Then remove radix deps, install,
full build.
## Hard rules
- NEVER touch non-radix libraries or their wrappers: cmdk (command), vaul
(drawer), sonner, input-otp, react-day-picker (calendar), recharts (chart).
Report them as intentionally untouched.
- No Base UI counterpart: AspectRatio -> CSS aspect-ratio div; Label ->
native `<label>`; VisuallyHidden -> `sr-only`; Direction -> Direction
Provider (`direction` prop, not `dir`). Popover Anchor and NavigationMenu
Indicator have no equivalent: inert passthrough + flag.
- `button.tsx` migrates to the REAL `@base-ui/react/button` primitive, never
a hand-rolled useRender wrapper.
- Behavior deltas are FLAGGED, never silently patched (tabs manual
activation, menu items not closing on click, nav-menu 50ms delay). The
target is idiomatic Base UI matching the shadcn base registry.
- Honest reporting: skipped/reverted files are listed as flagged, never as
migrated. Pre-existing failures are named as pre-existing.
## Verify and report
Typecheck per file, build per batch, full build at the end vs the baseline.
Reports live in a `.migration/` directory at the project root, ONE FILE PER
COMPONENT: `.migration/<component>.md` (e.g. `.migration/accordion.md`).
Rules:
- Each run writes (or fully overwrites) the file for each component it
migrated. Re-running a component replaces its report; never touch other
components' files.
- A multi-component run ("migrate alert-dialog and dropdown-menu") writes one
file per component, each self-contained; shared consumer-sweep notes are
repeated in every affected file.
- Whole-project mode writes the per-component files plus
`.migration/project.md` (dependency swap, app-code sweep summary, final
build result).
- There is NO index file. Migration status is derived from disk, not
maintained: scan the project's ui directory (the `ui` alias from shadcn
info, e.g. components/ui or src/components/ui) for remaining radix imports
when asked "what's left". End every run's summary with that derived count
("N wrappers remain on Radix").
Each `.migration/<component>.md` uses EXACTLY this structure (it is
documented publicly; reports must match it):
```md
# <component>
<date, strategy used (golden pair via CLI / merge / engine), one-line verdict>
## Changed
<every file touched, with what changed and why; include file:line for
anything notable. Confirm the leftover scan is clean:
grep -n "radix-ui\|@radix-ui" on this component's files>
## Left alone
<files that look related but were intentionally not touched, with the reason
(cmdk/vaul/sonner are not radix; unrelated drift; etc.)>
## Behavior changes
<differences that compile fine but act differently; flagged, never patched
(tabs activation, menu close-on-click, delays...). Empty section if none>
## Verify by hand
<short manual QA checklist for this primitive family: focus return on
dialogs, keyboard nav + typeahead on menus/select, tooltip delay feel,
slider commit events. Concrete steps, one minute of clicking>
```
universal-patterns.md›
# 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) `[email protected]` package exports, (3) base-ui.com docs index for
`@base-ui/[email protected]`. 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 primitive | Base UI target | Class |
|---|---|---|
| Accordion | Accordion | direct (Content->Panel) |
| AlertDialog | Alert Dialog | restructured (Overlay->Backdrop, Content->Popup, Cancel->Close, Action dropped) |
| AspectRatio | none | missing: plain div + CSS `aspect-ratio` (`--ratio` var) |
| Avatar | Avatar | direct |
| Checkbox | Checkbox | direct (cleanest 1:1) |
| Collapsible | Collapsible | direct (Content->Panel) |
| ContextMenu | Context Menu | restructured (menu mapping) |
| Dialog | Dialog | restructured (Overlay->Backdrop, Content->Popup) |
| DropdownMenu | Menu | RENAMED + restructured (canonical menu mapping) |
| Form | Form + Field + Fieldset | restructured (split into three) |
| HoverCard | Preview Card | RENAMED + positioner model |
| Label | none | missing: native `<label>` (Field.Label inside forms) |
| Menubar | Menubar + Menu | restructured (menubar root only; menus delegate to Menu) |
| NavigationMenu | Navigation Menu | heavily restructured (Viewport -> Positioner/Popup/Viewport, Indicator->Icon) |
| Popover | Popover | positioner model (Anchor dropped; verify vs docs) |
| Progress | Progress | restructured (new Track/Label/Value parts, no manual transform) |
| RadioGroup | Radio Group + Radio | restructured (Item -> Radio.Root, two subpath imports) |
| ScrollArea | Scroll Area | direct (Scrollbar/Thumb renames) |
| Select | Select | restructured (Viewport->List, ScrollButtons->ScrollArrows, alignItemWithTrigger) |
| Separator | Separator | direct (callable; `decorative` dropped) |
| Slider | Slider | restructured (Range->Indicator, new Control, thumbAlignment) |
| Switch | Switch | direct (1:1) |
| Tabs | Tabs | direct (Trigger->Tab, Content->Panel) |
| Toast | Toast | restructured (not in our registry pairs; spec from docs; shadcn users mostly use sonner) |
| Toggle | Toggle | direct (callable) |
| ToggleGroup | Toggle Group + Toggle | direct (items use Toggle primitive) |
| Toolbar | Toolbar | direct-ish (not in our pairs; spec from docs) |
| Tooltip | Tooltip | positioner model (delayDuration->delay on Provider) |
| unstable_OneTimePasswordField | OTP Field | from docs (our registry uses input-otp instead) |
| unstable_PasswordToggleField | none | missing: Input + custom toggle |
Utilities:
| Radix utility | Base UI equivalent |
|---|---|
| Slot / asChild | `render` prop; `useRender` + `mergeProps` for the manual Slot idiom |
| Portal | none standalone; per-component `Portal` parts |
| VisuallyHidden | none; `sr-only` class |
| AccessibleIcon | none; aria-label + sr-only text |
| Direction | Direction 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 part | Base UI part |
|---|---|
| `*.Root` (single-part comps) | callable `*Primitive` |
| `Overlay` | `Backdrop` |
| `Content` (overlay comps) | `Popup` (inside `Positioner`) |
| `Content` (accordion/collapsible/tabs) | `Panel` |
| tabs `Trigger` | `Tab` |
| menu `Label` | `GroupLabel` |
| menu `ItemIndicator` | `CheckboxItemIndicator` / `RadioItemIndicator` |
| `Sub` / `SubTrigger` | `SubmenuRoot` / `SubmenuTrigger` |
| slider `Range` | `Indicator` (+ new `Control`) |
| select `Viewport` | `List` |
| select `ScrollUp/DownButton` | `ScrollUp/DownArrow` |
| scroll-area `ScrollAreaScrollbar` / `ScrollAreaThumb` | `Scrollbar` / `Thumb` |
| nav-menu `Indicator` | `Icon` |
| nav-menu `Viewport` | `Positioner > Popup > Viewport` |
| hover-card `HoverCard*` | `PreviewCard*` |
| radio-group `Item` / `Indicator` | `Radio.Root` / `Radio.Indicator` |
| popover `Anchor` | dropped (verify against docs) |
| alert-dialog `Cancel` / `Action` | `Close` / dropped (plain Button) |
| separator `decorative` prop | dropped |
| Label primitive | native `<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*).
### menus (dropdown-menu -> Menu; context-menu; menubar)
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>`.
### navigation-menu
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`.
### breadcrumb / marker (Slot users)
`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.
wrapper-shapes.md›
# Target wrapper shapes (golden-derived specifics)
Facts learned by diffing hand migrations against the shadcn base registry
wrappers. These close gaps the mapping tables cannot express: exact classes,
defaults, and composition shapes. When migrating shadcn-style wrappers,
prefer these shapes.
## Conventions
- Positioner parts get NO `data-slot` attribute; keep data-slot on the parts
the radix wrapper already had.
- Menu-family Positioner: `className="isolate z-50 outline-none"`; the Popup
KEEPS `z-50` and `outline-none` too. Tooltip: Popup keeps `z-50`,
Positioner gets `isolate z-50`. Select: `isolate z-50` lives on the Popup,
Positioner gets no class.
- The base registry adds `cn-<comp>-content-logical` (and for tooltip also
`cn-tooltip-arrow-logical`) companion classes next to the existing
`cn-<comp>-content` hooks on popover, tooltip, hover-card, dropdown,
context-menu, select, menubar popups. Add them when the source uses cn-*
hooks; skip for plain-Tailwind projects.
## Button
Base UI HAS a Button primitive: `import { Button as ButtonPrimitive } from
"@base-ui/react/button"`. A shadcn button.tsx with the Slot/asChild idiom
migrates to `<ButtonPrimitive>` directly (which supports `render`), NOT to a
hand-rolled useRender wrapper. Reserve useRender + mergeProps for
non-button polymorphic components (breadcrumb link, marker).
## Tooltip Arrow (literal classes)
```tsx
<TooltipPrimitive.Arrow
className={cn(
"cn-tooltip-arrow cn-tooltip-arrow-logical",
"data-[side=bottom]:top-1 data-[side=left]:right-[-13px] data-[side=left]:top-1/2! data-[side=left]:-translate-y-1/2 data-[side=right]:left-[-13px] data-[side=right]:top-1/2! data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5",
className
)}
/>
```
Verify against the current base registry tooltip before relying on the exact
pixel values; the shape (per-side offsets + translate, no rotation) is the
stable part. Golden default: `alignOffset = 0`, `sideOffset = 4`.
## DropdownMenu / ContextMenu SubContent
Compose the PUBLIC Content wrapper, do not rebuild from primitives:
```tsx
function DropdownMenuSubContent(props) {
return (
<DropdownMenuContent
align="start"
alignOffset={-3}
side="right"
sideOffset={0}
className={cn("w-auto", props.className)}
{...props}
/>
)
}
```
The `-3` / `0` defaults are load-bearing (visual alignment with the parent
menu). NOTE: the live registry shapes SubContent differently per menu:
context-menu is a true minimal compose (as above), while dropdown-menu's
SubContent duplicates the full content class list (including translucent menu
styling) rather than composing. When a golden pair exists, copy the golden
shape; this example is the fallback.
DANGER — do not confuse SubContent defaults with main-Content defaults. The
values above are ONLY for the *submenu* wrappers (DropdownMenuSubContent /
ContextMenuSubContent). The MAIN ContextMenuContent (the pointer-anchored
right-click menu) keeps its own positioning — do NOT apply
`side="right"`/`alignOffset` to it, or every right-click menu mispositions.
- ContextMenu SUBContent defaults: `align="start" alignOffset={4} side="right" sideOffset={0}`.
- DropdownMenu SUBContent defaults: `align="start" alignOffset={-3} side="right" sideOffset={0}`.
- Main Content (either): keep the wrapper's existing align/sideOffset; do not add a side.
## SubTrigger open styling
Base wrappers ADD `data-popup-open:bg-accent
data-popup-open:text-accent-foreground` to SubTrigger (no radix equivalent
class existed; the open styling was previously data-[state=open]).
## Select
- Bare re-export: `const Select = SelectPrimitive.Root` (no wrapper function,
no data-slot on Root). `SelectPrimitive.Root.Props` is GENERIC
(<Value, Multiple>), which breaks the usual ComponentProps pattern; the
bare re-export sidesteps it.
- Drop the radix `position` prop entirely; expose `alignItemWithTrigger`
(default true) picked from Positioner.Props, `sideOffset = 4`.
- Item anatomy: `ItemText` FIRST with `cn-select-item-text shrink-0
whitespace-nowrap`, then `ItemIndicator render={<span
className="cn-select-item-indicator" />}`.
- Scroll arrows get `top-0 w-full` / `bottom-0 w-full`; List has no classes.
## Accordion animation placement
`h-(--accordion-panel-height)`, `data-starting-style:h-0`, and
`data-ending-style:h-0` all go on the INNER div of the Panel (the element
that previously carried the radix height animation), not on the Panel itself.
## Tabs
The base registry accepts Base UI's manual-activation default (no
`activateOnFocus`), and does not forward `orientation` beyond what the radix
wrapper did. Match it: flag the behavior delta, do not patch it.