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