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