
Migrate Radix To Base
- 10.1k installs
- 120k repo stars
- Updated July 27, 2026
- shadcn/ui
migrate-radix-to-base is a skill that migrates React Radix and shadcn components to Base UI with golden-pair merges and per-file reports so the app stays buildable throughout.
About
migrate-radix-to-base moves shadcn wrappers and Radix compositions to @base-ui/react with precise mappings and per-component migration reports. Preflight requires shadcn info JSON, correct package manager installs, a clean git branch, and a baseline typecheck before dependency changes. Strategy prefers golden-pair fetches from ui.shadcn.com registry URLs, three-way git merge-file for customized wrappers, and mandatory grep sweeps for leftover radix imports even after clean merges. Progressive mode writes component-base.tsx files, repoints consumers one at a time, and flips components.json only after the last radix wrapper is gone. Whole-project mode migrates dependency order from leaf components like button and label, sweeps app code against consumer-props.md, then removes radix packages. Hard rules skip cmdk, vaul, sonner, calendar, and chart libraries, flag behavior deltas like tabs activation and menu close-on-click, and migrate button.tsx to real @base-ui/react/button. Reports land in .migration/component.md files with changed, left alone, behavior changes, and verify-by-hand sections. Legacy new-york styles use transformation engine only without style retargeting that would r.
- Preflight: shadcn info JSON, package manager detection, clean git, baseline typecheck.
- Golden-pair registry fetch with three-way merge and mandatory radix leftover grep sweep.
- Progressive strangler-fig mode with component-base.tsx and per-consumer repointing.
- Hard rules preserving cmdk, vaul, sonner, calendar, and recharts wrappers untouched.
- Per-component .migration reports with behavior deltas and manual QA verify checklists.
Migrate Radix To Base by the numbers
- 10,077 all-time installs (skills.sh)
- +2,812 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #50 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
migrate-radix-to-base capabilities & compatibility
- Capabilities
- shadcn info preflight and package manager detect · golden pair registry fetch and three way merge · progressive per component strangler migration · consumer call site prop migration against refere · per component migration reports with behavior de
- Use cases
- frontend · refactoring
What migrate-radix-to-base says it does
Be precise; never guess a mapping.
A clean merge is NOT proof of a clean file.
NEVER touch non-radix libraries or their wrappers: cmdk (command), vaul (drawer), sonner
npx skills add https://github.com/shadcn/ui --skill migrate-radix-to-baseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10.1k |
|---|---|
| repo stars | ★ 120k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | shadcn/ui ↗ |
How do I migrate my shadcn or Radix React components to Base UI without breaking builds or silently changing behavior?
Migrate React shadcn or hand-rolled Radix UI components to Base UI while keeping the project buildable at every step.
Who is it for?
React teams on shadcn with radix-* styles who need a staged or whole-project migration to @base-ui/react with honest behavior delta flags.
Skip if: Skip for greenfield projects already on Base UI or non-React stacks without Radix-based shadcn wrappers.
When should I use this skill?
User asks to migrate from radix, move to base-ui, convert radix primitives, or switch a shadcn project base library.
What you get
Migrated Base UI wrappers, updated consumer imports, clean radix grep sweeps, and .migration per-component reports with verify-by-hand checklists.
Files
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.tsxmigrates to the REAL@base-ui/react/buttonprimitive, 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):
# <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>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-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/react@1.6.0 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 |
| 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) |
| 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"\ | ...` |
| 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)` |
| Slider | onValueCommit | onValueCommitted | rename |
| ToggleGroup | `type="single"\ | "multiple"` | multiple boolean; value shape arrays |
| 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.
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/react@1.6.0 .d.ts files (the published docs page for accordion lagged; types are authoritative here).
Conventions that apply to every component below:
asChild(boolean, defaultfalse) →render(ReactElement | (props: HTMLProps, state) => ReactElement). Signature changed: instead of a lone child element, pass the element torender; Base UI merges props onto it. Button-rendering parts additionally acceptnativeButton(defaulttrue), set it tofalsewhenrenderproduces a non-<button>element.- Base UI
classNameandstylealso accept a(state) => valuefunction 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
eventDetailsargument ({ reason, event, cancel(), ... }). - Radix
dirprops are dropped everywhere; Base UI reads direction from the DOMdirattribute /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 |
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 |
orientation | `"vertical" \ | "horizontal" / "vertical"` | — dropped (prop exists but deprecated no-op) |
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) |
Base UI only props worth knowing
Root.hiddenUntilFound/Panel.hiddenUntilFound(defaultfalse): useshidden="until-found"so browser find-in-page can expand panels; overrideskeepMounted. 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(defaulttrue).className/stylestate-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) |
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) |
Base UI only props worth knowing
Panel.hiddenUntilFound(defaultfalse): find-in-page support viahidden="until-found"; overrideskeepMounted.Trigger.nativeButton(defaulttrue).className/stylestate-function forms.
Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `Root/Content [data-state="open" \ | "closed"]` | Panel [data-open] / [data-closed] |
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') |
dir | `"ltr" \ | "rtl"` / — | — dropped |
activationMode | `"automatic" \ | "manual" / "automatic"` | moved + renamed: List.activateOnFocus (boolean, default false) |
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) |
Base UI only props worth knowing
Tabs.Indicator: new part, a<span>that tracks the active tab for sliding-highlight UIs;renderBeforeHydration(defaultfalse) for SSR-flash avoidance. Exposes the--active-tab-*CSS vars below.List.activateOnFocus(see above).Tab.nativeButton, state-functionclassName/styleon all parts.
Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `Trigger [data-state="active" \ | "inactive"]` | Tab [data-active] (presence) |
| `Content [data-state="active" \ | "inactive"]` | Panel [data-hidden] (presence when hidden) |
[data-orientation] (all parts) | [data-orientation] (Root, List, Tab, Panel, Indicator) | Same. |
Trigger [data-disabled] | Tab [data-disabled] | Same. |
| — | [data-activation-direction] (`'left' \ | 'right' \ |
| — | 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 UIToggleGroup(this replaces RadixToggleGroup.Item'svalue, see toggle-group below).nativeButton(defaulttrue), state-functionclassName/style.
Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `[data-state="on" \ | "off"]` | [data-pressed] (presence) |
[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) |
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') |
dir | `"ltr" \ | "rtl"` / — | — dropped |
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
Toggles, so per-itemonPressedChangeis available. - State-function
className/style.
Data-attribute mapping
| Radix | Base UI | Note |
|---|---|---|
| `Item [data-state="on" \ | "off"]` | Toggle [data-pressed] (presence) |
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') |
dir | `"ltr" \ | "rtl"` / — | — dropped |
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) |
value / defaultValue | string or string[] / — | value / defaultValue on ToggleGroup | Always an array (see toggle-group). |
onValueChange | `(value: string \ | string[]) => void` / — | onValueChange on ToggleGroup |
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-leveldisabled(defaultfalse).Toolbar.Input(new part):<input>wired into toolbar arrow-key navigation;defaultValue,disabled(defaultfalse),focusableWhenDisabled(defaulttrue).Button.disabled(defaultfalse) +Button.focusableWhenDisabled(defaulttrue): disabled buttons remain focusable for discoverability; setfocusableWhenDisabled={false}for Radix-like behavior.Button.nativeButton(defaulttrue), state-functionclassName/styleon 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) |
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.
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
classNameandstylealso accept a function of the part'sStateobject. - Every Base part exposes
Part.PropsandPart.Statetypes (e.g.Progress.Root.Props).
---
progress
Part mapping: Progress.Root → Progress.Root, Progress.Indicator → Progress.Indicator (now MUST be nested in the new Progress.Track). Base UI adds Track, Label, Value parts. The primitive computes the Indicator fill width itself (inline style), so the Radix pattern style={{ transform: translateX(-(100 - value)%) }} on Indicator is deleted, not ported.
Progress.Root → Progress.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
asChild | boolean / false | render | Signature changed (see header). |
value | `number \ | null` / - | value |
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 \ |
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>,childrenrender 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" \ |
scrollHideDelay | number / 600 | Dropped | Reproduce with a CSS transition-delay on the scrollbar's opacity transition. |
dir | `"ltr" \ | "rtl"` / - | Dropped |
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 |
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 }>, default0), 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"]` |
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 |
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" \ |
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' \ |
swipeDirection | `"right" \ | "left" \ | "up" \ |
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 |
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 |
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, default3; overflowing toasts getdata-limited+inertinstead of being removed),toastManager(fromToast.createToastManager()for use outside React). - Manager API (
useToastManager()return /createToastManager()):toasts,add(options) => id,close(id?),update(id, options),promise(promise, { loading, success, error }). - New parts:
Toast.Content(clips overflow while the stack is collapsed;data-behind,data-expanded),Toast.Positioner/Toast.Arrowfor anchored toasts (full popup positioning surface:anchor,sidedefault'top',align,sideOffset,alignOffset,collisionAvoidance,collisionBoundary,collisionPadding,arrowPadding,sticky,positionMethod,disableAnchorTracking).
Data attributes
| Radix | Base UI |
|---|---|
| Root `[data-state="open" \ | "closed"]` |
| Root `[data-swipe="start" \ | "move" \ |
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' \ |
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) |
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 (
validationModeon Form or per Field,validationDebounceTime). actionsRefimperativevalidate()on both Form and Field.Root.Field.Itemgroups a single checkbox/radio inside a group with its own label/description (disabledprop).- Focus is moved to the first invalid field on submit, matching Radix behavior.
Data attributes
| 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.
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, defaultfalse) ->render(Base UI,ReactElement | ((props, state) => ReactElement)). Every Base UI part acceptsrender, plusclassName/styleas either plain values or state callbacks ((state) => ...).- Base UI parts that render interactive elements accept
nativeButton(tells Base UI whether therendertarget is a native<button>), which has no Radix equivalent. - Callbacks fire with a second
eventDetailsargument ({ reason, event, cancel(), allowPropagation(), isCanceled, isPropagationAllowed, trigger }).eventDetails.cancel()replaces Radix'sevent.preventDefault()pattern for preventing default component behavior. - Radix
data-state="x"tokens become presence attributes in Base UI (data-checked,data-unchecked,data-open, ...). Base UI adds Field-integration attributes everywhere (data-valid,data-invalid,data-dirty,data-touched,data-filled,data-focused) and animation attributes (data-starting-style,data-ending-style). - Radix
dirprops have no Base UI per-component equivalent, direction comes fromDirectionProvider(or thedirHTML attribute).
---
select
Part mapping: Root -> Root, Trigger -> Trigger, Value -> Value, Icon -> Icon, Portal -> Portal, Content -> Portal > Positioner > Popup (split into three parts), Viewport -> List, Item -> Item, ItemText -> ItemText, ItemIndicator -> ItemIndicator, ScrollUpButton -> ScrollUpArrow, ScrollDownButton -> ScrollDownArrow, Group -> Group, Label -> GroupLabel (Base UI's Select.Label is a NEW part that labels the trigger, not groups), Separator -> Separator, Arrow -> Arrow.
Select.Root → Select.Root
| Radix prop | Type / default | Base UI equivalent | Migration note |
|---|---|---|---|
defaultValue | string, no default | `defaultValue: Value[] \ | Value \ |
value | string, no default | `value: Value[] \ | Value \ |
onValueChange | (value: string) => void | `onValueChange: (value: Value[] \ | Value \ |
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 |
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 \ |
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 |
side | `"top" \ | "right" \ | "bottom" \ |
sideOffset | number, default 0 | `sideOffset: number \ | OffsetFunction on Positioner, default 0` |
align | `"start" \ | "center" \ | "end", default "start"` |
alignOffset | number, default 0 | `alignOffset: number \ | OffsetFunction on Positioner, default 0` |
avoidCollisions | boolean, default true | collisionAvoidance: CollisionAvoidance on Positioner | Signature changed: boolean becomes a config object `{ side: 'flip' \ |
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 |
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 |
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 \ |
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(defaultfalse): multi-select withValue[]values, no Radix equivalent.Root.items:Record<string, ReactNode> | { label, value }[] | Group[], letsSelect.Valuerender labels instead of raw values.Root.isItemEqualToValue,Root.itemToStringLabel,Root.itemToStringValue: object-value support.Root.modal: boolean(defaulttrue): scroll lock + outside pointer blocking; Radix select was always modal-ish, setmodal={false}for non-modal behavior.Root.readOnly,Root.autoComplete,Root.form,Root.inputRef,Root.id,Root.onOpenChangeComplete,Root.actionsRef({ unmount() }for externally controlled exit animations),Root.highlightItemOnHover(defaulttrue).- New parts:
Select.Backdrop(overlay under the popup),Select.Label(trigger label),Popup.finalFocus. Positioner.anchor,Positioner.positionMethod('absolute' | 'fixed'),Positioner.disableAnchorTracking.
Data-attribute mapping (select)
| Radix | Base UI |
|---|---|
| Trigger `data-state="open" \ | "closed"` |
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"` |
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-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 |
checked | `boolean \ | 'indeterminate'`, no default | checked: boolean + indeterminate: boolean |
onCheckedChange | `(checked: boolean \ | 'indeterminate') => void` | onCheckedChange: (checked: boolean, eventDetails: Checkbox.Root.ChangeEventDetails) => void |
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(defaultfalse): the mixed state, decoupled fromchecked.Root.parent: boolean+CheckboxGroup(new component,base-ui.com/react/components/checkbox-group):<CheckboxGroup value/defaultValue/onValueChange(string[], eventDetails)/allValues/disabled>provides shared state for a set of checkboxes and enables a parent "select all" checkbox. No Radix equivalent, new capability.Root.readOnly: boolean,Root.uncheckedValue: string(value submitted when unchecked),Root.form: string,Root.inputRef,Root.id,Root.nativeButton.
Data-attribute mapping (checkbox)
| 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 |
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(defaultfalse).
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[]` |
value | number[], no default | `value: number \ | number[]` |
onValueChange | (value: number[]) => void | `onValueChange: (value: number \ | number[], eventDetails: Slider.Root.ChangeEventDetails) => void` |
onValueCommit | (value: number[]) => void | `onValueCommitted: (value: number \ | number[], eventDetails: Slider.Root.CommitEventDetails) => void` |
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' |
dir | `"ltr" \ | "rtl"`, no default | dropped |
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', setthumbAlignment="edge"to keep the thumb inside the track bounds.Root.thumbCollisionBehavior: 'push' | 'swap' | 'none'(default'push'): range-slider thumb collision handling (Radix behavior was closest to'none').Root.largeStep: number(default10): Page Up/Down and Shift+Arrow increment.Root.format: Intl.NumberFormatOptionsandRoot.locale: Intl.LocalesArgument: value formatting forSlider.Valueandaria-valuetext.- New parts:
Slider.Value(renders<output>,children: (formattedValues: string[], values: number[]) => ReactNode),Slider.Label(auto-associated label).
Data-attribute mapping (slider)
| 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).
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 \ |
dir (`"ltr" \ | "rtl"`) on roots |
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' \ |
modal | boolean / true | modal (boolean, true) | Same. |
dir | `"ltr" \ | "rtl"` / – | – |
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 \ |
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 \ |
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` / – | – |
forceMount | boolean / – | keepMounted on Portal | Moved; animate with data-starting-style/data-ending-style. |
side | `"top" \ | "right" \ | "bottom" \ |
sideOffset | number / 0 | sideOffset on Positioner (`number \ | OffsetFunction, 0`) |
align | `"start" \ | "center" \ | "end" / "center"` |
alignOffset | number / 0 | alignOffset on Positioner (`number \ | OffsetFunction, 0`) |
avoidCollisions | boolean / true | collisionAvoidance on Positioner (CollisionAvoidance) | Signature changed: object `{ side?: 'flip' \ |
collisionBoundary | `Element \ | null \ | Array<Element \ |
collisionPadding | `number \ | Padding / 0` | collisionPadding on Positioner (Padding, 5) |
arrowPadding | number / 0 | arrowPadding on Positioner (number, 5) | Same; default changes 0 → 5. |
sticky | `"partial" \ | "always" / "partial"` | – (see note) |
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) |
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' \ |
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.HandleviaMenu.createHandle()),triggerId/defaultTriggerId, payload-awarechildrenrender 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 (closeOnClickdefaultfalse).- All parts:
className/styleaccept state-callback form(state) => ....
Data-attribute mapping (dropdown/context/menubar menus)
| Radix | Base UI |
|---|---|
| Trigger `[data-state="open" \ | "closed"]` |
| Content `[data-state="open" \ | "closed"]` |
| – | data-starting-style / data-ending-style (CSS transition hooks, replace animating on data-state) |
| Content `[data-side="left" \ | "right" \ |
| Content `[data-align="start" \ | "end" \ |
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" \ |
ItemIndicator [data-state] | data-checked / data-unchecked + data-starting-style / data-ending-style on the split indicators |
| SubTrigger `[data-state="open" \ | "closed"]` |
| – | Popup data-instant (`'click' \ |
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"` / – | – |
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`) |
(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"` / – | – |
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;containerwidened.Content(loop,onCloseAutoFocus, outside/escape callbacks,forceMount,side/sideOffset/align/alignOffset,avoidCollisions,collisionBoundary,collisionPadding,arrowPadding,sticky,hideWhenDetached) →Menu.Portal > Menu.Positioner > Menu.Popupwith the exact fates listed for dropdown-menu Content.SubContentaligndefault"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
Menubarexposesdata-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/heightonMenu.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`) |
value | string / – | value (`Value \ | null, null`) |
onValueChange | (value: string) => void / – | onValueChange | Signature changed: `(value: Value \ |
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"` / – | – |
orientation | `"horizontal" \ | "vertical" / "horizontal"` | orientation (same values/default) |
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" \ |
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` / – | – |
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]` | – | – |
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 \ |
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.keepMountedfor crawler-visible SSR content.Link.closeOnClick.- All parts accept
className/stylestate-callback forms andrender.
Data-attribute mapping (navigation-menu)
| Radix | Base UI |
|---|---|
Root/Sub/List/Item [data-orientation] | Dropped. |
| Trigger `[data-state="open" \ | "closed"]` |
Trigger [data-disabled] | Dropped. |
| Content `[data-state="open" \ | "closed"]` |
| Content `[data-motion="to-start" \ | "to-end" \ |
Link [data-active] | data-active (same). |
| Indicator `[data-state="visible" \ | "hidden"]` |
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) => voidforonSelect/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 lacksdisabled. These are the three hard drops with no one-line workaround. - Base UI docs fetched from the
.mdendpoints reflect Base UI 1.x (repo currently pins 1.6.0).
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-slotattribute; 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)
<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:
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
positionprop entirely; exposealignItemWithTrigger
(default true) picked from Positioner.Props, sideOffset = 4.
- Item anatomy:
ItemTextFIRST 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.
Related skills
FAQ
What is progressive versus whole-project migration?
Progressive migrates one component via -base.tsx side files; whole-project flips components.json and removes radix after all wrappers migrate.
Which libraries does migrate-radix-to-base skip?
cmdk, vaul, sonner, input-otp, react-day-picker calendar, and recharts chart wrappers are intentionally untouched.
Is migrate-radix-to-base safe to install?
Review the Security Audits panel on this page before installing in production.