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:
bilalgursen
2026-07-20 07:46:41 +03:00
parent 4c76bd9724
commit 87f265ec15
84 changed files with 16020 additions and 89 deletions

View 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>
```

View 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.

View 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.

View 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.

View 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.

View 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).

View 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).

View 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). |

View 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.

View 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.

View File

@@ -0,0 +1,275 @@
---
name: shadcn
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI, including chat interfaces. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
# shadcn/ui
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
## Current Project Context
```json
!`npx shadcn@latest info --json`
```
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
## Principles
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
## Critical Rules
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
### Styling & Tailwind → [styling.md](./rules/styling.md)
- **`className` for layout, not styling.** Never override component colors or typography.
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
### Forms & Inputs → [forms.md](./rules/forms.md)
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
- **Option sets (27 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
### Component Structure → [composition.md](./rules/composition.md)
- **Items always inside their Group.** `SelectItem``SelectGroup`. `DropdownMenuItem``DropdownMenuGroup`. `CommandItem``CommandGroup`.
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
- **Callouts use `Alert`.** Don't build custom styled divs.
- **Empty states use `Empty`.** Don't build custom empty state markup.
- **Toast via `sonner`.** Use `toast()` from `sonner`.
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
- **Use `Badge`** instead of custom styled spans.
### Icons → [icons.md](./rules/icons.md)
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
### Chat & Messaging → [chat.md](./rules/chat.md)
- **Chat UI composes the chat primitives.** Conversations use `MessageScroller`, rows use `Message`, surfaces use `Bubble`. Never hand-rolled bubble `div`s or a raw scroll container.
- **`MessageScroller` owns scroll behavior.** Streaming follow, anchoring, and jump-to-latest (`MessageScrollerButton`) are built in. Don't write a `useStickToBottom`/`ResizeObserver` hook.
- **Attachments use `Attachment`; system notes and dividers use `Marker`.** Not `Item` cards or `Separator` + a label.
### CLI
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
## Key Patterns
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
```tsx
// Form layout: FieldGroup + Field, not div + Label.
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" />
</Field>
</FieldGroup>
// Validation: data-invalid on Field, aria-invalid on the control.
<Field data-invalid>
<FieldLabel>Email</FieldLabel>
<Input aria-invalid />
<FieldDescription>Invalid email.</FieldDescription>
</Field>
// Icons in buttons: data-icon, no sizing classes.
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
// Spacing: gap-*, not space-y-*.
<div className="flex flex-col gap-4"> // correct
<div className="space-y-4"> // wrong
// Equal dimensions: size-*, not w-* h-*.
<Avatar className="size-10"> // correct
<Avatar className="w-10 h-10"> // wrong
// Status colors: Badge variants or semantic tokens, not raw colors.
<Badge variant="secondary">+20.1%</Badge> // correct
<span className="text-emerald-600">+20.1%</span> // wrong
```
## Component Selection
| Need | Use |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| Button/action | `Button` with appropriate variant |
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
| Toggle between 25 options | `ToggleGroup` + `ToggleGroupItem` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
| Command palette | `Command` inside `Dialog` |
| Charts | `Chart` (wraps Recharts) |
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
| Empty states | `Empty` |
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
| Chat / conversation UI | `MessageScroller`, `Message`, `Bubble`, `Attachment`, `Marker` |
## Key Fields
The injected project context contains these key fields:
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
See [cli.md — `info` command](./cli.md) for the full field reference.
## Component Docs, Examples, and Usage
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
```bash
npx shadcn@latest docs button dialog select
```
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
## Workflow
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
3. **Find components**`npx shadcn@latest search`.
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
5. **Install or update**`npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
## Updating Components
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
3. Decide per file based on the diff:
- No local changes → safe to overwrite.
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
- User says "just update everything" → use `--overwrite`, but confirm first.
4. **Never use `--overwrite` without the user's explicit approval.**
## Quick Reference
```bash
# Create a new project.
npx shadcn@latest init --name my-app --preset base-nova
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
# Create a monorepo project.
npx shadcn@latest init --name my-app --preset base-nova --monorepo
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
# Initialize existing project.
npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
# Apply a preset to an existing project.
npx shadcn@latest apply a2r6bw
npx shadcn@latest apply a2r6bw --only theme
npx shadcn@latest apply a2r6bw --only font
npx shadcn@latest apply a2r6bw --only theme,font
# Inspect preset codes and project preset state.
npx shadcn@latest preset decode a2r6bw
npx shadcn@latest preset url a2r6bw
npx shadcn@latest preset open a2r6bw
npx shadcn@latest preset resolve
npx shadcn@latest preset resolve --json
# Add components.
npx shadcn@latest add button card dialog
npx shadcn@latest add @magicui/shimmer-button
npx shadcn@latest add owner/repo/item
npx shadcn@latest add --all
# Preview changes before adding/updating.
npx shadcn@latest add button --dry-run
npx shadcn@latest add button --diff button.tsx
npx shadcn@latest add @acme/form --view button.tsx
npx shadcn@latest add owner/repo/item --dry-run
# Search registries.
npx shadcn@latest search @shadcn -q "sidebar"
npx shadcn@latest search @tailark -q "stats"
npx shadcn@latest search owner/repo -q "login"
npx shadcn@latest search # all configured registries
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
# Get component docs and example URLs.
npx shadcn@latest docs button dialog select
# View registry item details (for items not yet installed).
npx shadcn@latest view @shadcn/button
npx shadcn@latest view owner/repo/item
```
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
## Detailed References
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
- [rules/chat.md](./rules/chat.md) — MessageScroller, Message, Bubble, Attachment, Marker; streaming, anchoring, jump-to-latest
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
- [cli.md](./cli.md) — Commands, flags, presets, templates
- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules
- [customization.md](./customization.md) — Theming, CSS variables, extending components

View File

@@ -0,0 +1,5 @@
interface:
display_name: "shadcn/ui"
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
icon_small: "./assets/shadcn-small.png"
icon_large: "./assets/shadcn.png"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,290 @@
# shadcn CLI Reference
Configuration is read from `components.json`.
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
## Contents
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
- Templates: next, vite, start, react-router, astro
- Presets: named, code, URL formats and fields
- Switching presets
---
## Commands
### `init` — Initialize or create a project
```bash
npx shadcn@latest init [components...] [options]
```
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
| Flag | Short | Description | Default |
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `true` |
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
| `--force` | `-f` | Force overwrite existing configuration | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--name <name>` | `-n` | Name for new project | — |
| `--silent` | `-s` | Mute output | `false` |
| `--rtl` | | Enable RTL support | — |
| `--reinstall` | | Re-install existing UI components | `false` |
| `--monorepo` | | Scaffold a monorepo project | — |
| `--no-monorepo` | | Skip the monorepo prompt | — |
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
### `apply` — Apply a preset to an existing project
```bash
npx shadcn@latest apply [preset] [options]
```
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
| Flag | Short | Description | Default |
| ------------------- | ----- | ------------------------------------------ | ------- |
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--silent` | `-s` | Mute output | `false` |
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
### `add` — Add components
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
```bash
npx shadcn@latest add [components...] [options]
```
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`),
GitHub item addresses (`owner/repo/item`), URLs, or local paths.
| Flag | Short | Description | Default |
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--overwrite` | `-o` | Overwrite existing files | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--all` | `-a` | Add all available components | `false` |
| `--path <path>` | `-p` | Target path for the component | — |
| `--silent` | `-s` | Mute output | `false` |
| `--dry-run` | | Preview all changes without writing files | `false` |
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
#### Dry-Run Mode
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
```bash
# Preview all changes.
npx shadcn@latest add button --dry-run
# Show diffs for all files (top 5).
npx shadcn@latest add button --diff
# Show the diff for a specific file.
npx shadcn@latest add button --diff button.tsx
# Show contents for all files (top 5).
npx shadcn@latest add button --view
# Show the full content of a specific file.
npx shadcn@latest add button --view button.tsx
# Works with URLs too.
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
# Works with public GitHub registries too.
npx shadcn@latest add owner/repo/item --dry-run
# CSS diffs.
npx shadcn@latest add button --diff globals.css
```
**When to use dry-run:**
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
- Before overwriting existing components — use `--diff` to preview the changes first.
- When the user wants to inspect component source code without installing — use `--view`.
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
#### Smart Merge from Upstream
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
### `search` — Search registries
```bash
npx shadcn@latest search [registries...] [options]
```
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
and registry catalog URLs. Without `-q`, lists all items. When no registries are
passed, searches every registry configured in `components.json`.
| Flag | Short | Description | Default |
| ------------------- | ----- | ------------------------------------------------- | ------- |
| `--query <query>` | `-q` | Search query | — |
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
| `--limit <number>` | `-l` | Max items to display | `100` |
| `--offset <number>` | `-o` | Items to skip | `0` |
| `--json` | | Output as JSON | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
### `view` — View item details
```bash
npx shadcn@latest view <items...> [options]
```
Displays item info including file contents. Examples:
`npx shadcn@latest view @shadcn/button`,
`npx shadcn@latest view owner/repo/item`.
### `docs` — Get component documentation URLs
```bash
npx shadcn@latest docs <components...> [options]
```
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
Example output for `npx shadcn@latest docs input button`:
```
base radix
input
docs https://ui.shadcn.com/docs/components/radix/input
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
button
docs https://ui.shadcn.com/docs/components/radix/button
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
```
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
### `diff` — Check for updates
Do not use this command. Use `npx shadcn@latest add --diff` instead.
### `info` — Project information
```bash
npx shadcn@latest info [options]
```
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
| Flag | Short | Description | Default |
| ------------- | ----- | ----------------- | ------- |
| `--cwd <cwd>` | `-c` | Working directory | current |
**Project Info fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------ |
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
| `isRSC` | `boolean` | Whether React Server Components are enabled |
| `isTsx` | `boolean` | Whether the project uses TypeScript |
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
| `tailwindCssFile` | `string` | Path to the global CSS file |
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
**Components.json fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
| `rsc` | `boolean` | RSC flag from config |
| `tsx` | `boolean` | TypeScript flag |
| `tailwind.config` | `string` | Tailwind config path |
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
| `registries` | `object` | Configured custom registries |
**Links fields:**
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
### `build` — Build a custom registry
```bash
npx shadcn@latest build [registry] [options]
```
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
For authoring rules, `include`, item definitions, `registryDependencies`, and
GitHub registry behavior, see [registry.md](./registry.md).
| Flag | Short | Description | Default |
| ----------------- | ----- | ----------------- | ------------ |
| `--output <path>` | `-o` | Output directory | `./public/r` |
| `--cwd <cwd>` | `-c` | Working directory | current |
---
## Templates
| Value | Framework | Monorepo support |
| -------------- | -------------- | ---------------- |
| `next` | Next.js | Yes |
| `vite` | Vite | Yes |
| `start` | TanStack Start | Yes |
| `react-router` | React Router | Yes |
| `astro` | Astro | Yes |
| `laravel` | Laravel | No |
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
---
## Presets
Three ways to specify a preset via `--preset`:
1. **Named:** `--preset nova` or `--preset lyra`
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
## Switching Presets
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.

View File

@@ -0,0 +1,209 @@
# Customization & Theming
Components reference semantic CSS variable tokens. Change the variables to change every component.
## Contents
- How it works (CSS variables → Tailwind utilities → components)
- Color variables and OKLCH format
- Dark mode setup
- Changing the theme (presets, CSS variables)
- Adding custom colors (Tailwind v3 and v4)
- Border radius
- Customizing components (variants, className, wrappers)
- Checking for updates
---
## How It Works
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
3. Components use these utilities — changing a variable changes all components that reference it.
---
## Color Variables
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
| Variable | Purpose |
| -------------------------------------------- | -------------------------------- |
| `--background` / `--foreground` | Page background and default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary buttons and actions |
| `--secondary` / `--secondary-foreground` | Secondary actions |
| `--muted` / `--muted-foreground` | Muted/disabled states |
| `--accent` / `--accent-foreground` | Hover and accent states |
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
| `--border` | Default border color |
| `--input` | Form input borders |
| `--ring` | Focus ring color |
| `--chart-1` through `--chart-5` | Chart/data visualization |
| `--sidebar-*` | Sidebar-specific colors |
| `--surface` / `--surface-foreground` | Secondary surface |
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (01), chroma (0 = gray), and hue (0360).
---
## Dark Mode
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
```tsx
import { ThemeProvider } from "next-themes"
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
```
---
## Changing the Theme
```bash
# Apply a preset code from ui.shadcn.com.
npx shadcn@latest apply --preset a2r6bw
# Positional shorthand also works.
npx shadcn@latest apply a2r6bw
# Switch to a named preset and overwrite existing components.
npx shadcn@latest apply --preset nova
# Preserve existing components instead.
npx shadcn@latest init --preset nova --force --no-reinstall
# Use a custom theme URL.
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
```
Or edit CSS variables directly in `globals.css`.
---
## Adding Custom Colors
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
```css
/* 1. Define in the global CSS file. */
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
```
```css
/* 2a. Register with Tailwind v4 (@theme inline). */
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
```js
// 2b. Register with Tailwind v3 (tailwind.config.js).
module.exports = {
theme: {
extend: {
colors: {
warning: "oklch(var(--warning) / <alpha-value>)",
"warning-foreground":
"oklch(var(--warning-foreground) / <alpha-value>)",
},
},
},
}
```
```tsx
// 3. Use in components.
<div className="bg-warning text-warning-foreground">Warning</div>
```
---
## Border Radius
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
---
## Customizing Components
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">
Click
</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="mx-auto max-w-md">...</Card>
```
### 3. Add a new variant
Edit the component source to add a variant via `cva`:
```tsx
// components/ui/button.tsx
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
```
### 4. Wrapper components
Compose shadcn/ui primitives into higher-level components:
```tsx
export function ConfirmDialog({ title, description, onConfirm, children }) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
```
---
## Checking for Updates
```bash
npx shadcn@latest add button --diff
```
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
```bash
npx shadcn@latest add button --dry-run # see all affected files
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
```
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.

View File

@@ -0,0 +1,77 @@
{
"skill_name": "shadcn",
"evals": [
{
"id": 1,
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
"files": [],
"expectations": [
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
"No manual dark: color overrides"
]
},
{
"id": 2,
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
"files": [],
"expectations": [
"Includes DialogTitle for accessibility (visible or with sr-only class)",
"Avatar component includes AvatarFallback",
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
"Uses asChild for custom triggers (radix preset)"
]
},
{
"id": 3,
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
"files": [],
"expectations": [
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
"Uses Badge component for percentage change instead of custom styled spans",
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
"Uses gap-* instead of space-y-* or space-x-* for spacing",
"Uses size-* when width and height are equal instead of separate w-* h-*"
]
},
{
"id": 4,
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Build a chat conversation view: a scrollable thread of messages from two different people, each with an avatar, sender name, timestamp, and message bubble. A couple of messages include an image attachment and a PDF file attachment, and there's a 'Today' divider separating the days.",
"expected_output": "A React component composing MessageScroller, Message, Bubble, Attachment, and Marker from the registry instead of hand-rolled bubble/divider/attachment markup.",
"files": [],
"expectations": [
"Uses MessageScroller (MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem) for the scrollable thread instead of a raw overflow-y-auto div or ScrollArea",
"Wraps each row in MessageScrollerItem inside MessageScrollerContent",
"Uses Message with MessageAvatar/MessageContent/MessageHeader for row layout instead of custom flex divs",
"Uses Bubble + BubbleContent for the message surface instead of a styled div with bg-muted/bg-primary",
"Uses Attachment (AttachmentMedia, AttachmentContent, AttachmentTitle, AttachmentDescription) for the file and image attachments instead of Item or a custom card",
"Uses Marker (variant=\"separator\") for the 'Today' divider instead of Separator plus a centered label",
"Uses semantic color tokens and gap-* spacing; no raw colors like bg-emerald-500 and no space-y-*",
"Includes \"use client\" when the component uses state or event handlers (isRSC)"
]
},
{
"id": 5,
"prompt": "Using shadcn/ui (base-nova preset, lucide icons), build a streaming AI chat UI. The assistant's reply streams in while it generates, the view auto-scrolls to follow the latest content but stops following if the user scrolls up to read earlier messages, a 'jump to latest' button appears when the user has scrolled away from the bottom, and a subtle 'thinking…' shimmer shows while the model is generating.",
"expected_output": "A React component that delegates scroll/anchor behavior to MessageScroller and uses MessageScrollerButton for jump-to-latest and the shimmer utility for the thinking indicator — no hand-rolled scroll logic or custom shimmer keyframes.",
"files": [],
"expectations": [
"Uses MessageScroller with MessageScrollerProvider (autoScroll) and scrollAnchor on message items for the stick-to-bottom/follow behavior instead of a custom useStickToBottom hook or ResizeObserver/scrollTop wiring",
"Uses MessageScrollerButton for the jump-to-latest control instead of a hand-built conditional button driven by manual scroll-position state",
"Uses the shimmer utility class for the 'thinking…' indicator instead of a custom @keyframes or bg-clip-text gradient animation",
"Wraps each message row in MessageScrollerItem inside MessageScrollerContent",
"Uses Message + Bubble + BubbleContent for the conversation rows instead of hand-rolled bubble divs",
"Uses semantic color tokens and gap-* spacing; includes \"use client\" (isRSC)"
]
}
]
}

View File

@@ -0,0 +1,105 @@
# shadcn MCP Server
The CLI includes an MCP server that lets AI assistants search, browse, view, and install items from registries.
---
## Setup
```bash
shadcn mcp # start the MCP server (stdio)
shadcn mcp init # write config for your editor
```
Editor config files:
| Editor | Config file |
| ----------- | ------------------------------- |
| Claude Code | `.mcp.json` |
| Cursor | `.cursor/mcp.json` |
| VS Code | `.vscode/mcp.json` |
| OpenCode | `opencode.json` |
| Codex | `~/.codex/config.toml` (manual) |
---
## Tools
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
### `shadcn:get_project_registries`
Returns registry names from `components.json`. Errors if no `components.json` exists.
**Input:** none
### `shadcn:list_items_in_registries`
Lists all items from one or more registries. Registries can be configured
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or
registry catalog URLs. Omit `registries` to list from every registry configured
in `components.json`.
**Input:** `registries` (string[], optional — omit for all configured), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
### `shadcn:search_items_in_registries`
Fuzzy search across registries. Registries can be configured namespaces, public
GitHub sources, or registry catalog URLs. Omit `registries` to search every
registry configured in `components.json` — e.g. "find me a hero" across all
configured registries.
**Input:** `registries` (string[], optional — omit for all configured), `query` (string), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
### `shadcn:view_items_in_registries`
View item details including full file contents.
**Input:** `items` (string[]) — e.g.
`["@shadcn/button", "@shadcn/card", "owner/repo/item"]`
### `shadcn:get_item_examples_from_registries`
Find usage examples and demos with source code. Omit `registries` to search
every registry configured in `components.json`.
**Input:** `registries` (string[], optional — omit for all configured), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
### `shadcn:get_add_command_for_items`
Returns the CLI install command.
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
### `shadcn:get_audit_checklist`
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
**Input:** none
---
## Configuring Registries
Namespaced and authenticated registries are set in `components.json`. The
`@shadcn` registry is always built-in. Public GitHub registries can also be used
directly as `owner/repo` registry sources when the repository has a root
`registry.json`; they do not need `components.json` configuration.
```json
{
"registries": {
"@acme": "https://acme.com/r/{name}.json",
"@private": {
"url": "https://private.com/r/{name}.json",
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
}
}
}
```
- Names must start with `@`.
- URLs must contain `{name}`.
- `${VAR}` references are resolved from environment variables.
Community registry index: `https://ui.shadcn.com/r/registries.json`

View File

@@ -0,0 +1,277 @@
# Registry Authoring and Addresses
Use this reference when the user wants to create, fix, publish, or reason about
a shadcn registry.
## Mental Model
A registry has two forms:
- **Source registry**: an authored `registry.json` in a project or repository.
It may use `include` and file paths that point at source files.
- **Built registry**: generated JSON files served to CLI consumers, usually
from `public/r`. Use `npx shadcn@latest build` to create this form.
The CLI installer consumes registry item payloads. A source registry is a way to
author those payloads from real files.
Registry items are not limited to React components. They can distribute
components, hooks, utilities, design tokens, pages, config files, docs, rules,
workflows, templates, MCP files, and other project files.
## Root `registry.json`
The root registry file should define registry metadata and either `items` or
`include`.
```json
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme",
"homepage": "https://acme.com",
"items": [
{
"name": "absolute-url",
"type": "registry:lib",
"title": "Absolute URL",
"description": "A utility to turn any path into an absolute URL.",
"files": [
{
"path": "lib/absolute-url.ts",
"type": "registry:lib"
}
]
}
]
}
```
Root registry rules:
- Root `registry.json` must include `name` and `homepage`.
- `items` is an array of registry item definitions.
- `include` may be used to split the source registry into multiple files.
- Included registry files may omit `name` and `homepage`.
## Include
Use `include` to keep large registries modular.
```json
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme",
"homepage": "https://acme.com",
"include": ["registry/ui/registry.json", "registry/blocks/registry.json"]
}
```
Include rules:
- Include paths are relative to the `registry.json` that declares them.
- Include paths must explicitly point to a `registry.json` file.
- Do not use remote URLs, absolute paths, or parent traversal (`..`).
- Item file paths are relative to the registry file that declares the item.
- Duplicate item names fail across the resolved registry.
Example included file:
```json
{
"items": [
{
"name": "button",
"type": "registry:ui",
"files": [
{
"path": "button.tsx",
"type": "registry:ui"
}
]
}
]
}
```
If this file is at `registry/ui/registry.json`, then `button.tsx` is read from
`registry/ui/button.tsx`, and the built item path is emitted relative to the
root registry.
## Item Definitions
Common item fields:
```json
{
"name": "login-form",
"type": "registry:block",
"title": "Login Form",
"description": "A login form with email and password fields.",
"dependencies": ["zod"],
"registryDependencies": ["button", "input", "label"],
"files": [
{
"path": "blocks/login-form.tsx",
"type": "registry:block"
}
],
"cssVars": {
"light": {
"brand": "oklch(0.62 0.18 250)"
},
"dark": {
"brand": "oklch(0.72 0.16 250)"
}
}
}
```
Important fields:
- `name`: the installable item name. It is not necessarily a file path.
- `type`: one of the registry item types, such as `registry:ui`,
`registry:block`, `registry:lib`, `registry:hook`, `registry:file`,
`registry:page`, `registry:theme`, `registry:style`, `registry:font`, or
`registry:item`.
- `files`: source files copied or generated by the item.
- `dependencies`: npm runtime dependencies.
- `devDependencies`: npm development dependencies.
- `registryDependencies`: other registry items required by this item.
- `cssVars`, `css`, `tailwind`, `envVars`, and `docs`: optional install-time
additions.
File rules:
- File paths are relative to the declaring `registry.json`.
- `registry:file` and `registry:page` files require a `target`.
- Do not use remote file URLs in source registry file paths.
- Keep source files copy-pasteable: no hidden app-only imports.
## Registry Dependencies
`registryDependencies` entries are item addresses, not file paths.
```json
{
"name": "login-form",
"type": "registry:block",
"registryDependencies": ["button", "@acme/input", "acme/ui/card#v1.2.0"],
"files": [
{
"path": "blocks/login-form.tsx",
"type": "registry:block"
}
]
}
```
Dependency rules:
- Bare names such as `"button"` mean official shadcn items.
- Bare names never mean same-registry or same-repository items.
- Namespaced dependencies use `@namespace/item-name`.
- GitHub dependencies use `owner/repo/item-name`.
- Pin GitHub dependencies with `owner/repo/item-name#ref` when needed.
- Refs are not inherited. If `owner/repo/foo#v2` depends on `bar` from the same
repo at `v2`, write `owner/repo/bar#v2`.
- Do not use relative dependencies such as `"./bar"`.
## Address Schemes
When reasoning about a registry item string, classify it first.
| Address | Scheme | Meaning |
| ----------------------------------- | --------- | ------------------------------------------------------------ |
| `button` | shadcn | Official shadcn item named `button`. |
| `@acme/button` | namespace | Item `button` from configured registry `@acme`. |
| `@acme/ui/button` | namespace | Item `ui/button` from configured registry `@acme`. |
| `https://example.com/r/button.json` | url | Built registry item JSON at that URL. |
| `./button.json` | file | Built registry item JSON on disk. |
| `acme/ui/button` | github | Item `button` from GitHub repo `acme/ui`. |
| `acme/ui/forms/login#main` | github | Item `forms/login` from GitHub repo `acme/ui` at ref `main`. |
For namespace and GitHub addresses, slashful item names are allowed and are item
names, not file paths. Addresses ending in `.json` keep file-address
precedence, so `acme/ui/data/schema.json` is treated as a file path, not a
GitHub item address.
## GitHub Registries
A public GitHub repository can act as a source registry when it has a root
`registry.json`.
```txt
owner/repo/item-name[#ref]
```
Rules:
- The first two path segments are GitHub owner and repo.
- All remaining path segments are the registry item name.
- The source entrypoint is always root `registry.json`.
- GitHub registries are source registries consumed directly by the CLI. They do
not require `shadcn build` or generated item JSON files.
- `include` follows the same source-registry rules as local registries.
- Currently, GitHub addresses support public `github.com` repositories only.
- Private repos and GitHub Enterprise require explicit product decisions.
When implementing GitHub registry fetching, resolve refs to a commit SHA before
reading source files. Do not read moving refs directly from
`raw.githubusercontent.com`, because branch-like refs can be cached for several
minutes.
Preferred flow:
```txt
owner/repo[#ref]
-> resolve ref with git ls-remote
-> commit SHA
-> read https://raw.githubusercontent.com/{owner}/{repo}/{sha}/registry.json
-> read includes and item files from the same SHA
```
This keeps a command on one consistent repository snapshot.
Full 40-character commit SHAs are already stable and can be used directly.
Branches, tags, and short refs require Git so the CLI can resolve them to a
commit SHA first.
## Build and Verify
Use the CLI to build source registries:
```bash
npx shadcn@latest build
npx shadcn@latest build registry.json --output public/r
```
Use CLI commands to inspect the result:
```bash
npx shadcn@latest list @acme
npx shadcn@latest search @acme -q "login"
npx shadcn@latest view @acme/login-form
npx shadcn@latest add @acme/login-form --dry-run
npx shadcn@latest registry validate ./registry.json
```
Use GitHub addresses directly for public GitHub registries:
```bash
npx shadcn@latest list owner/repo
npx shadcn@latest search owner/repo -q "login"
npx shadcn@latest view owner/repo/item
npx shadcn@latest add owner/repo/item --dry-run
npx shadcn@latest registry validate owner/repo
```
When working on registry implementation in the shadcn/ui codebase:
- Keep address parsing pure and testable.
- Do not add side effects to validators.
- Preserve existing behavior for official shadcn, namespace, URL, and file
schemes.
- Add tests for address parsing, source loading, dependency resolution, list,
search, view, and add paths.
- Prefer small source-reader abstractions over a plugin system until there are
multiple real providers.

View File

@@ -0,0 +1,306 @@
# Base vs Radix
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
## Contents
- Composition: asChild vs render
- Button / trigger as non-button element
- Select (items prop, placeholder, positioning, multiple, object values)
- ToggleGroup (type vs multiple)
- Slider (scalar vs array)
- Accordion (type and defaultValue)
---
## Composition: asChild (radix) vs render (base)
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
**Incorrect:**
```tsx
<DialogTrigger>
<div>
<Button>Open</Button>
</div>
</DialogTrigger>
```
**Correct (radix):**
```tsx
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
```
**Correct (base):**
```tsx
<DialogTrigger render={<Button />}>Open</DialogTrigger>
```
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
---
## Button / trigger as non-button element (base only)
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
**Incorrect (base):** missing `nativeButton={false}`.
```tsx
<Button render={<a href="/docs" />}>Read the docs</Button>
```
**Correct (base):**
```tsx
<Button render={<a href="/docs" />} nativeButton={false}>
Read the docs
</Button>
```
**Correct (radix):**
```tsx
<Button asChild>
<a href="/docs">Read the docs</a>
</Button>
```
Same for triggers whose `render` is not a `Button`:
```tsx
// base.
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
Pick date
</PopoverTrigger>
```
---
## Select
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
**Incorrect (base):**
```tsx
<Select>
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
</Select>
```
**Correct (base):**
```tsx
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
]
<Select items={items}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
```
**Correct (radix):**
```tsx
<Select>
<SelectTrigger>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
```
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
```tsx
// base.
<SelectContent alignItemWithTrigger={false} side="bottom">
// radix.
<SelectContent position="popper">
```
---
## Select — multiple selection and object values (base only)
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
**Correct (base — multiple selection):**
```tsx
<Select items={items} multiple defaultValue={[]}>
<SelectTrigger>
<SelectValue>
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
</SelectValue>
</SelectTrigger>
...
</Select>
```
**Correct (base — object values):**
```tsx
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
<SelectTrigger>
<SelectValue>{(value) => value.name}</SelectValue>
</SelectTrigger>
...
</Select>
```
---
## ToggleGroup
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
**Incorrect (base):**
```tsx
<ToggleGroup type="single" defaultValue="daily">
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
</ToggleGroup>
```
**Correct (base):**
```tsx
// Single (no prop needed), defaultValue is always an array.
<ToggleGroup defaultValue={["daily"]} spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup multiple>
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Correct (radix):**
```tsx
// Single, defaultValue is a string.
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup type="multiple">
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Controlled single value:**
```tsx
// base — wrap/unwrap arrays.
const [value, setValue] = React.useState("normal")
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
// radix — plain string.
const [value, setValue] = React.useState("normal")
<ToggleGroup type="single" value={value} onValueChange={setValue}>
```
---
## Slider
Base accepts a plain number for a single thumb. Radix always requires an array.
**Incorrect (base):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
**Correct (base):**
```tsx
<Slider defaultValue={50} max={100} step={1} />
```
**Correct (radix):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
```tsx
// base.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
// radix.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={setValue} />
```
---
## Accordion
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
**Incorrect (base):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```
**Correct (base):**
```tsx
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
// Multi-select.
<Accordion multiple defaultValue={["item-1", "item-2"]}>
<AccordionItem value="item-1">...</AccordionItem>
<AccordionItem value="item-2">...</AccordionItem>
</Accordion>
```
**Correct (radix):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```

View File

@@ -0,0 +1,224 @@
# Chat & Messaging
Components for conversation and chat UI. Compose these instead of hand-rolling
bubbles, scroll containers, dividers, or attachment cards.
Install: `npx shadcn@latest add message-scroller message bubble attachment marker`
The same component names and props ship for both `base` and `radix`; only
composition differs (`render` vs `asChild`). See [base-vs-radix.md](./base-vs-radix.md).
## Contents
- Scrollable threads use MessageScroller
- Message rows use Message
- Message surfaces use Bubble
- Attachments use Attachment
- System notes and dividers use Marker
- Streaming, anchoring, and jump-to-latest are built in
- Escape hatch: the scroller hooks
---
## Scrollable threads use MessageScroller
A conversation that scrolls, follows new messages, restores position, or jumps
to a message uses `MessageScroller`. Don't build a raw overflow container with
manual scroll wiring, and don't reach for `ScrollArea`.
The parts nest in a fixed order. Every direct child of the content is wrapped in
a `MessageScrollerItem` so the scroller can measure, anchor, preserve position,
track visibility, and jump to it. `MessageScrollerButton` sits inside
`MessageScroller`, after the viewport.
**Incorrect:**
```tsx
// Hand-rolled scroll container with manual stick-to-bottom logic.
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-6 p-4">
{messages.map((m) => (
<ChatMessage key={m.id} message={m} />
))}
</div>
</div>
```
**Correct:**
```tsx
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message align={message.role === "user" ? "end" : "start"}>
{/* ...message content... */}
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
```
---
## Message rows use Message
`Message` lays out a single row: avatar, header, content, footer, with
alignment. Group consecutive rows from one sender with `MessageGroup`. Don't
rebuild the row from flex divs.
`align="end"` is the current user's side; `align="start"` is everyone else.
```tsx
<Message align="start">
<MessageAvatar>
<Avatar>
<AvatarImage src={sender.avatar} alt={sender.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<MessageHeader>{sender.name}</MessageHeader>
<Bubble>
<BubbleContent>{text}</BubbleContent>
</Bubble>
<MessageFooter>{time}</MessageFooter>
</MessageContent>
</Message>
```
---
## Message surfaces use Bubble
The colored message surface is `Bubble` + `BubbleContent`, never a styled `div`
with `bg-muted` / `bg-primary` and hand-managed corners.
- `variant`: `default`, `secondary`, `muted`, `tinted`, `outline`, `ghost`, `destructive`.
- `align`: `start` or `end` (matches the `Message` side).
`BubbleReactions` renders the reaction cluster. `side` (`top` | `bottom`) and
`align` (`start` | `end`) position it against the bubble. Don't lay reactions out
with absolutely-positioned `Badge`s.
**Incorrect:**
```tsx
<div className="w-fit rounded-2xl bg-primary px-3 py-2 text-primary-foreground">
{text}
</div>
```
**Correct:**
```tsx
<Bubble variant="default" align="end">
<BubbleContent>{text}</BubbleContent>
<BubbleReactions side="bottom" align="end">
<Badge variant="secondary">👍 2</Badge>
</BubbleReactions>
</Bubble>
```
---
## Attachments use Attachment
File and image attachments use `Attachment`, not `Item` or a custom card. It
carries upload state, so wire `state` to the real status rather than rendering a
separate spinner.
- `state`: `idle`, `uploading`, `processing`, `error`, `done`. `uploading` and
`processing` apply the `shimmer` animation to the title automatically.
- `size`: `default`, `sm`, `xs`. `orientation`: `horizontal`, `vertical`.
- Use `AttachmentGroup` to lay out several attachments in a scrolling row.
```tsx
<Attachment state="done">
<AttachmentMedia variant="icon">
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>homepage-feedback.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction>
<DownloadIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
```
For an image, use `<AttachmentMedia variant="image">` with an `img` child.
---
## System notes and dividers use Marker
Status lines ("Sarah joined the conversation"), date dividers ("Today"), and
labeled separators are `Marker`, not a `Separator` plus a centered span.
- `variant`: `default` (plain row), `separator` (centered label with rules on
each side), `border` (bottom-bordered row).
- `MarkerIcon` holds a leading icon; `MarkerContent` holds the label.
**Incorrect:**
```tsx
<div className="flex items-center gap-3 py-2">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">Today</span>
<Separator className="flex-1" />
</div>
```
**Correct:**
```tsx
<Marker variant="separator">
<MarkerContent>Today</MarkerContent>
</Marker>
```
---
## Streaming, anchoring, and jump-to-latest are built in
`MessageScroller` handles the behavior that chat UIs usually reinvent. Don't
write a `useStickToBottom` hook, a `ResizeObserver`, or manual `scrollTop` math.
- **Follow the live edge while streaming.** `MessageScrollerProvider` with
`autoScroll` keeps the view pinned to new content and yields the moment the
user scrolls up. Streaming token updates that grow the last message are
followed automatically.
- **Anchor a turn.** `scrollAnchor` on a `MessageScrollerItem` marks the row to
hold in view (typically the user's message that started the turn).
- **Jump to latest.** `MessageScrollerButton` appears when the user scrolls away
and scrolls back on click. `direction="end"` (default) or `direction="start"`.
It is a self-managing control, so don't gate it behind your own scroll-position
state.
For a "thinking…" indicator while the model generates, apply the `shimmer`
utility to text. Don't author a custom keyframe animation. See
[styling.md](./styling.md).
---
## Escape hatch: the scroller hooks
For behavior the parts don't expose, read state from the hooks rather than
re-implementing the scroller: `useMessageScroller`,
`useMessageScrollerVisibility`, and `useMessageScrollerScrollable`. They come
from the auto-installed `@shadcn/react` dependency, so there's nothing extra to
install. Reach for them only when composition can't express what you need.

View File

@@ -0,0 +1,201 @@
# Component Composition
## Contents
- Items always inside their Group component
- Callouts use Alert
- Empty states use Empty component
- Toast notifications use sonner
- Choosing between overlay components
- Dialog, Sheet, and Drawer always need a Title
- Card structure
- Button has no isPending or isLoading prop
- TabsTrigger must be inside TabsList
- Avatar always needs AvatarFallback
- Use Separator instead of raw hr or border divs
- Use Skeleton for loading placeholders
- Use Badge instead of custom styled spans
---
## Items always inside their Group component
Never render items directly inside the content container.
**Incorrect:**
```tsx
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
```
**Correct:**
```tsx
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
```
This applies to all group-based components:
| Item | Group |
|------|-------|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
| `MenubarItem` | `MenubarGroup` |
| `ContextMenuItem` | `ContextMenuGroup` |
| `CommandItem` | `CommandGroup` |
| `MessageScrollerItem` | `MessageScrollerContent` |
| `Message` (consecutive, same sender) | `MessageGroup` |
| `Bubble` (stacked) | `BubbleGroup` |
| `Attachment` (in a row) | `AttachmentGroup` |
Chat components nest in a fixed order (`MessageScrollerProvider``MessageScroller``MessageScrollerViewport``MessageScrollerContent``MessageScrollerItem`). See [chat.md](./chat.md).
---
## Callouts use Alert
```tsx
<Alert>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>Something needs attention.</AlertDescription>
</Alert>
```
---
## Empty states use Empty component
```tsx
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Create Project</Button>
</EmptyContent>
</Empty>
```
---
## Toast notifications use sonner
```tsx
import { toast } from "sonner"
toast.success("Changes saved.")
toast.error("Something went wrong.")
toast("File deleted.", {
action: { label: "Undo", onClick: () => undoDelete() },
})
```
---
## Choosing between overlay components
| Use case | Component |
|----------|-----------|
| Focused task that requires input | `Dialog` |
| Destructive action confirmation | `AlertDialog` |
| Side panel with details or filters | `Sheet` |
| Mobile-first bottom panel | `Drawer` |
| Quick info on hover | `HoverCard` |
| Small contextual content on click | `Popover` |
---
## Dialog, Sheet, and Drawer always need a Title
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
```tsx
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update your profile.</DialogDescription>
</DialogHeader>
...
</DialogContent>
```
---
## Card structure
Use full composition — don't dump everything into `CardContent`:
```tsx
<Card>
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>Manage your team.</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>
<Button>Invite</Button>
</CardFooter>
</Card>
```
---
## Button has no isPending or isLoading prop
Compose with `Spinner` + `data-icon` + `disabled`:
```tsx
<Button disabled>
<Spinner data-icon="inline-start" />
Saving...
</Button>
```
---
## TabsTrigger must be inside TabsList
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
```tsx
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">...</TabsContent>
</Tabs>
```
---
## Avatar always needs AvatarFallback
Always include `AvatarFallback` for when the image fails to load:
```tsx
<Avatar>
<AvatarImage src="/avatar.png" alt="User" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
```
---
## Use existing components instead of custom markup
| Instead of | Use |
|---|---|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |

View File

@@ -0,0 +1,192 @@
# Forms & Inputs
## Contents
- Forms use FieldGroup + Field
- InputGroup requires InputGroupInput/InputGroupTextarea
- Buttons inside inputs use InputGroup + InputGroupAddon
- Option sets (27 choices) use ToggleGroup
- FieldSet + FieldLegend for grouping related fields
- Field validation and disabled states
---
## Forms use FieldGroup + Field
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
```tsx
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" />
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" type="password" />
</Field>
</FieldGroup>
```
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
**Choosing form controls:**
- Simple text input → `Input`
- Dropdown with predefined options → `Select`
- Searchable dropdown → `Combobox`
- Native HTML select (no JS) → `native-select`
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
- Single choice from few options → `RadioGroup`
- Toggle between 25 options → `ToggleGroup` + `ToggleGroupItem`
- OTP/verification code → `InputOTP`
- Multi-line text → `Textarea`
---
## InputGroup requires InputGroupInput/InputGroupTextarea
Never use raw `Input` or `Textarea` inside an `InputGroup`.
**Incorrect:**
```tsx
<InputGroup>
<Input placeholder="Search..." />
</InputGroup>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
</InputGroup>
```
---
## Buttons inside inputs use InputGroup + InputGroupAddon
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
**Incorrect:**
```tsx
<div className="relative">
<Input placeholder="Search..." className="pr-10" />
<Button className="absolute right-0 top-0" size="icon">
<SearchIcon />
</Button>
</div>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<Button size="icon">
<SearchIcon data-icon="inline-start" />
</Button>
</InputGroupAddon>
</InputGroup>
```
---
## Option sets (27 choices) use ToggleGroup
Don't manually loop `Button` components with active state.
**Incorrect:**
```tsx
const [selected, setSelected] = useState("daily")
<div className="flex gap-2">
{["daily", "weekly", "monthly"].map((option) => (
<Button
key={option}
variant={selected === option ? "default" : "outline"}
onClick={() => setSelected(option)}
>
{option}
</Button>
))}
</div>
```
**Correct:**
```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
<ToggleGroup spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
</ToggleGroup>
```
Combine with `Field` for labelled toggle groups:
```tsx
<Field orientation="horizontal">
<FieldTitle id="theme-label">Theme</FieldTitle>
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
<ToggleGroupItem value="system">System</ToggleGroupItem>
</ToggleGroup>
</Field>
```
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
---
## FieldSet + FieldLegend for grouping related fields
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
```tsx
<FieldSet>
<FieldLegend variant="label">Preferences</FieldLegend>
<FieldDescription>Select all that apply.</FieldDescription>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox id="dark" />
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>
```
---
## Field validation and disabled states
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
```tsx
// Invalid.
<Field data-invalid>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" aria-invalid />
<FieldDescription>Invalid email address.</FieldDescription>
</Field>
// Disabled.
<Field data-disabled>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" disabled />
</Field>
```
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.

View File

@@ -0,0 +1,101 @@
# Icons
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide``lucide-react`, `tabler``@tabler/icons-react`, etc. Never assume `lucide-react`.
---
## Icons in Button use data-icon attribute
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="mr-2 size-4" />
Search
</Button>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start"/>
Search
</Button>
<Button>
Next
<ArrowRightIcon data-icon="inline-end"/>
</Button>
```
---
## No sizing classes on icons inside components
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="size-4" data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon className="mr-2 size-4" />
Settings
</DropdownMenuItem>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
```
---
## Pass icons as component objects, not string keys
Use `icon={CheckIcon}`, not a string key to a lookup map.
**Incorrect:**
```tsx
const iconMap = {
check: CheckIcon,
alert: AlertIcon,
}
function StatusBadge({ icon }: { icon: string }) {
const Icon = iconMap[icon]
return <Icon />
}
<StatusBadge icon="check" />
```
**Correct:**
```tsx
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
import { CheckIcon } from "lucide-react"
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
return <Icon />
}
<StatusBadge icon={CheckIcon} />
```

View File

@@ -0,0 +1,185 @@
# Styling & Customization
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
## Contents
- Semantic colors
- Built-in variants first
- className for layout only
- No space-x-* / space-y-*
- Prefer size-* over w-* h-* when equal
- Prefer truncate shorthand
- No manual dark: color overrides
- Use cn() for conditional classes
- No manual z-index on overlay components
- Use shimmer / scroll-fade utilities, not custom animations
---
## Semantic colors
**Incorrect:**
```tsx
<div className="bg-blue-500 text-white">
<p className="text-gray-600">Secondary text</p>
</div>
```
**Correct:**
```tsx
<div className="bg-primary text-primary-foreground">
<p className="text-muted-foreground">Secondary text</p>
</div>
```
---
## No raw color values for status/state indicators
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
**Incorrect:**
```tsx
<span className="text-emerald-600">+20.1%</span>
<span className="text-green-500">Active</span>
<span className="text-red-600">-3.2%</span>
```
**Correct:**
```tsx
<Badge variant="secondary">+20.1%</Badge>
<Badge>Active</Badge>
<span className="text-destructive">-3.2%</span>
```
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
---
## Built-in variants first
**Incorrect:**
```tsx
<Button className="border border-input bg-transparent hover:bg-accent">
Click me
</Button>
```
**Correct:**
```tsx
<Button variant="outline">Click me</Button>
```
---
## className for layout only
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
**Incorrect:**
```tsx
<Card className="bg-blue-100 text-blue-900 font-bold">
<CardContent>Dashboard</CardContent>
</Card>
```
**Correct:**
```tsx
<Card className="max-w-md mx-auto">
<CardContent>Dashboard</CardContent>
</Card>
```
To customize a component's appearance, prefer these approaches in order:
1. **Built-in variants**`variant="outline"`, `variant="destructive"`, etc.
2. **Semantic color tokens**`bg-primary`, `text-muted-foreground`.
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
---
## No space-x-* / space-y-*
Use `gap-*` instead. `space-y-4``flex flex-col gap-4`. `space-x-2``flex gap-2`.
```tsx
<div className="flex flex-col gap-4">
<Input />
<Input />
<Button>Submit</Button>
</div>
```
---
## Prefer size-* over w-* h-* when equal
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
---
## Prefer truncate shorthand
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
---
## No manual dark: color overrides
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
---
## Use cn() for conditional classes
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
**Incorrect:**
```tsx
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
```
**Correct:**
```tsx
import { cn } from "@/lib/utils"
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
```
---
## No manual z-index on overlay components
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
---
## Use shimmer / scroll-fade utilities, not custom animations
For a live "thinking…" or loading-text shimmer, apply the `shimmer` utility. Don't author a custom `@keyframes` or a `bg-clip-text` gradient sweep.
For scroll-aware edge fading on a scroll container, use `scroll-fade` (and the axis variants `scroll-fade-x` / `scroll-fade-b`). Don't hand-roll mask gradients. The chat components already apply these internally: `Attachment` shimmers its title during upload, and `MessageScrollerViewport` fades its edges.
**Incorrect:**
```tsx
<span className="animate-pulse bg-gradient-to-r from-muted-foreground/40 via-foreground/70 to-muted-foreground/40 bg-clip-text text-transparent [animation:shimmer_1.6s_infinite]">
Thinking
</span>
```
**Correct:**
```tsx
<span className="shimmer">Thinking</span>
```