Deft
Ask AI

Search

Search skills, authors and topics

PrototyperAI / prototyper-ui

create-component

Create new Prototyper UI components from scratch, including Base UI primitives, registry entries, examples, and MDX documentation.

Install

npx skills add https://github.com/PrototyperAI/prototyper-ui --skill create-component
tools
[Read, Glob, Grep, Write, Edit, Bash]

Prototyper UI — Creating Components

End-to-end guide for building new components in the Prototyper UI library. Templates first, then decision trees, then architecture patterns, then registration and documentation.


1. Copy-Paste Templates

These four templates cover every component shape. Pick one, fill in the blanks.

Template A: Simple Component (no sub-components)

Used by: Button, Toggle

tsx
"use client";
import { ComponentName as ComponentNamePrimitive } from "@base-ui/react/component-name";import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const componentNameVariants = cva(  [    "inline-flex items-center justify-center gap-2 whitespace-nowrap",    "rounded-md text-sm font-medium",    "transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth",    "motion-reduce:transition-none",    "focus-visible:focus-ring",    "disabled:status-disabled",    "no-highlight",    "[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",  ],  {    variants: {      variant: {        default:          "bg-primary text-primary-foreground hover-only:hover:bg-primary-hover",        outline: "border border-border bg-surface hover-only:hover:bg-accent",      },      size: {        default: "h-9 px-4",        sm: "h-8 px-3 text-xs",        lg: "h-10 px-6",      },    },    defaultVariants: {      variant: "default",      size: "default",    },  },);
function ComponentName({  className,  variant,  size,  ...props}: ComponentNamePrimitive.Props & VariantProps<typeof componentNameVariants>) {  return (    <ComponentNamePrimitive      data-slot="component-name"      className={cn(componentNameVariants({ variant, size }), className)}      {...props}    />  );}
export { ComponentName, componentNameVariants };

Template B: Compound Visual Component (Root + sub-components sharing context)

Used by: Switch, Slider, Meter, Progress

tsx
"use client";
import * as React from "react";import { ComponentName as ComponentNamePrimitive } from "@base-ui/react/component-name";
import { cn } from "@/lib/utils";
// --- Context (only when sub-components need shared state) ---
type ComponentNameSize = "sm" | "md" | "lg";type ComponentNameContextValue = { size: ComponentNameSize };const ComponentNameCtx = React.createContext<ComponentNameContextValue>({  size: "md",});
// --- Root ---
function ComponentName({  className,  children,  size = "md",  ...props}: ComponentNamePrimitive.Root.Props & { size?: ComponentNameSize }) {  return (    <ComponentNameCtx value={{ size }}>      <ComponentNamePrimitive.Root        data-slot="component-name"        className={cn("group", className)}        {...props}      >        {children}      </ComponentNamePrimitive.Root>    </ComponentNameCtx>  );}
// --- Sub-component: Track ---
function ComponentNameTrack({  className,  ...props}: ComponentNamePrimitive.Track.Props) {  const { size } = React.useContext(ComponentNameCtx);  return (    <ComponentNamePrimitive.Track      data-slot="component-name-track"      className={cn(        "relative overflow-hidden rounded-full bg-muted",        size === "sm" && "h-1",        size === "md" && "h-2",        size === "lg" && "h-3",        className,      )}      {...props}    />  );}
// --- Sub-component: Indicator ---
function ComponentNameIndicator({  className,  ...props}: ComponentNamePrimitive.Indicator.Props) {  return (    <ComponentNamePrimitive.Indicator      data-slot="component-name-indicator"      className={cn(        "h-full bg-primary",        "transition-[width,background-color] duration-300 ease-smooth",        "motion-reduce:transition-none",        className,      )}      {...props}    />  );}
export { ComponentName, ComponentNameTrack, ComponentNameIndicator };

Template C: Positioned Overlay (Portal → Positioner → Popup)

Used by: Popover, Tooltip, Menu, Select, Combobox

tsx
"use client";
import { ComponentName as ComponentNamePrimitive } from "@base-ui/react/component-name";
import { cn } from "@/lib/utils";
// --- Root (pass-through) ---
function ComponentName(props: ComponentNamePrimitive.Root.Props) {  return <ComponentNamePrimitive.Root data-slot="component-name" {...props} />;}
// --- Trigger ---
function ComponentNameTrigger({  className,  ...props}: ComponentNamePrimitive.Trigger.Props) {  return (    <ComponentNamePrimitive.Trigger      data-slot="component-name-trigger"      className={cn(className)}      {...props}    />  );}
// --- Content (wraps Portal → Positioner → Popup internally) ---
function ComponentNameContent({  className,  align = "center",  alignOffset = 0,  side = "bottom",  sideOffset = 4,  ...props}: ComponentNamePrimitive.Popup.Props &  Pick<    ComponentNamePrimitive.Positioner.Props,    "align" | "alignOffset" | "side" | "sideOffset"  >) {  return (    <ComponentNamePrimitive.Portal>      <ComponentNamePrimitive.Positioner        align={align}        alignOffset={alignOffset}        side={side}        sideOffset={sideOffset}        className="isolate z-50"      >        <ComponentNamePrimitive.Popup          data-slot="component-name-content"          className={cn(            // Layout            "min-w-[8rem] rounded-lg p-1",            "bg-overlay text-overlay-foreground shadow-overlay",            // Enter animation            "data-open:animate-in data-open:duration-150",            "data-open:[animation-timing-function:var(--ease-out-fluid)]",            "data-open:fade-in-0 data-open:zoom-in-95",            // Exit animation            "data-closed:animate-out data-closed:duration-100",            "data-closed:[animation-timing-function:var(--ease-in-quart)]",            "data-closed:fade-out-0 data-closed:zoom-out-95",            // Direction-aware slide            "data-[side=bottom]:slide-in-from-top-2",            "data-[side=top]:slide-in-from-bottom-2",            "data-[side=left]:slide-in-from-right-2",            "data-[side=right]:slide-in-from-left-2",            // Origin + performance + accessibility            "origin-(--transform-origin)",            "data-entering:will-change-[opacity,transform]",            "data-exiting:will-change-[opacity,transform]",            "motion-reduce:animate-none motion-reduce:transition-none",            className,          )}          {...props}        />      </ComponentNamePrimitive.Positioner>    </ComponentNamePrimitive.Portal>  );}
export { ComponentName, ComponentNameTrigger, ComponentNameContent };

Template D: Dialog / Full-Screen Overlay (with backdrop)

Used by: Dialog, AlertDialog

tsx
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
function ComponentName(props: DialogPrimitive.Root.Props) {  return <DialogPrimitive.Root data-slot="component-name" {...props} />;}
function ComponentNameTrigger({  className,  ...props}: DialogPrimitive.Trigger.Props) {  return (    <DialogPrimitive.Trigger      data-slot="component-name-trigger"      className={cn(className)}      {...props}    />  );}
function ComponentNameOverlay({  className,  ...props}: DialogPrimitive.Backdrop.Props) {  return (    <DialogPrimitive.Backdrop      data-slot="component-name-overlay"      className={cn(        "fixed inset-0 z-50",        "bg-black/10 supports-backdrop-filter:backdrop-blur-xs",        "data-open:animate-in data-closed:animate-out",        "data-closed:fade-out-0 data-open:fade-in-0",        "duration-200",        "motion-reduce:animate-none motion-reduce:transition-none",        className,      )}      {...props}    />  );}
function ComponentNamePortal(props: DialogPrimitive.Portal.Props) {  return (    <DialogPrimitive.Portal data-slot="component-name-portal" {...props} />  );}
function ComponentNameContent({  className,  children,  ...props}: DialogPrimitive.Popup.Props) {  return (    <ComponentNamePortal>      <ComponentNameOverlay />      <DialogPrimitive.Popup        data-slot="component-name-content"        className={cn(          "fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2",          "w-full max-w-lg rounded-xl bg-overlay p-6 shadow-overlay",          "data-open:animate-in data-closed:animate-out",          "data-closed:fade-out-0 data-open:fade-in-0",          "data-closed:zoom-out-[0.98] data-open:zoom-in-[1.02]",          "duration-200 ease-out-fluid",          "data-entering:will-change-[opacity,transform]",          "data-exiting:will-change-[opacity,transform]",          "motion-reduce:animate-none motion-reduce:transition-none",          className,        )}        {...props}      >        {children}      </DialogPrimitive.Popup>    </ComponentNamePortal>  );}
// --- Presentational wrappers ---
function ComponentNameHeader({  className,  ...props}: React.ComponentProps<"div">) {  return (    <div      data-slot="component-name-header"      className={cn("flex flex-col gap-2", className)}      {...props}    />  );}
function ComponentNameFooter({  className,  ...props}: React.ComponentProps<"div">) {  return (    <div      data-slot="component-name-footer"      className={cn("flex justify-end gap-2", className)}      {...props}    />  );}
function ComponentNameTitle({  className,  ...props}: DialogPrimitive.Title.Props) {  return (    <DialogPrimitive.Title      data-slot="component-name-title"      className={cn("text-lg font-semibold", className)}      {...props}    />  );}
function ComponentNameDescription({  className,  ...props}: DialogPrimitive.Description.Props) {  return (    <DialogPrimitive.Description      data-slot="component-name-description"      className={cn("text-sm text-muted-foreground", className)}      {...props}    />  );}
function ComponentNameClose(props: DialogPrimitive.Close.Props) {  return <DialogPrimitive.Close data-slot="component-name-close" {...props} />;}
export {  ComponentName,  ComponentNameTrigger,  ComponentNameContent,  ComponentNameOverlay,  ComponentNamePortal,  ComponentNameHeader,  ComponentNameFooter,  ComponentNameTitle,  ComponentNameDescription,  ComponentNameClose,};

2. Component Type Decision Tree

What kind of component are you building?│├── A single interactive element? (button, toggle, link)│   └── Template A — Simple Component│       ├── Has variants? → CVA with variant/size│       └── No variants? → Plain function, no CVA│├── A visual compound? (track + thumb, indicator + label)│   └── Template B — Compound Visual│       ├── Sub-components need shared state? → Add React context│       └── No shared state needed? → Skip context, pass via className│├── A positioned popup? (appears near trigger)│   └── Template C — Positioned Overlay│       ├── Has items/list? → Add Item sub-component (like Menu, Select)│       ├── Has arrow? → Add Arrow sub-component│       └── Simple content? → Just Content with children│├── A modal/full-screen overlay? (blocks page interaction)│   └── Template D — Dialog Overlay│       ├── Side panel? → Add side= CVA variant (sheet)│       └── Centered? → Fixed center positioning│├── A form input?│   ├── Wraps a native <input>? → Template A + integrate with Field│   └── Custom control? → Template B + hidden input for form submission│└── A presentational wrapper? (header, footer, section)    └── Plain div with data-slot + className merge        function Wrapper({ className, ...props }: React.ComponentProps<"div">) {          return <div data-slot="wrapper" className={cn("...", className)} {...props} />        }

When to use which Base UI primitive

Does Base UI have a primitive for this component?│├── Yes → Import and wrap it│   ├── Single part (Button, Toggle) → Wrap directly│   └── Multi-part (Dialog, Popover, Slider) → Wrap each sub-part│├── Partially → Compose from multiple primitives│   (e.g., Toolbar uses Button primitive + custom keyboard nav)│└── No primitive exists → Invoke /build-primitive skill    ├── Presentational? → Semantic HTML + data-slot + CVA    ├── Interactive? → ARIA + keyboard + controlled state    ├── Form control? → + hidden inputs + Field compatibility    └── Complex composite? → + roving focus + focus trap

3. Step-by-Step Workflow

Phase 1: Research

  1. Check Base UI docs — Does a primitive exist? What sub-parts does it expose?
    https://base-ui.com/react/components/{component-name}
  2. Check existing Prototyper UI components — Is there a similar component to reference?
    ls apps/docs/registry/ui/
  3. Identify the template — Use the decision tree above to pick A, B, C, or D.

Phase 2: Implement

  1. Create component file at apps/docs/registry/ui/{component-name}.tsx
  2. Follow the file header pattern strictly:
    "use client"                          ← Always line 1import from "@base-ui/react/..."      ← Base UI (deep path, aliased)import { cva } from "cva"             ← CVA (only if variants needed)import { Icon } from "lucide-react"   ← Icons (only if needed)import { cn } from "@/lib/utils"      ← Internal utilities lastimport { Dep } from "@/registry/ui/dep" ← Internal component deps
  3. Implement each sub-component — one function per sub-component, not classes.
  4. Add data-slot to every rendered element.
  5. Add animation classes — invoke /animate-ui skill for exact patterns.
  6. Export all components and variants — named exports only, single export block at end.

Phase 3: Register

  1. Add registry entry to apps/docs/registry/registry.ts
  2. Create example files in apps/docs/registry/example/{component-name}/
  3. Run pnpm registry:build to generate JSON + __registry__.tsx

Phase 4: Document

  1. Create MDX page at apps/docs/content/docs/components/{component-name}.mdx
  2. Run pnpm dev and verify visually at localhost:3333

4. Base UI Wrapping Patterns

Import Convention

Always use deep path imports with an alias:

tsx
// Correct — deep path, aliased with "Primitive" suffix or component nameimport { Dialog as DialogPrimitive } from "@base-ui/react/dialog";import { Popover as PopoverPrimitive } from "@base-ui/react/popover";import { Button as ButtonPrimitive } from "@base-ui/react/button";
// Wrong — barrel importimport { Dialog } from "@base-ui/react";
// Wrong — no alias (conflicts with your wrapper name)import { Dialog } from "@base-ui/react/dialog";

Accessing Sub-Parts

Base UI exposes sub-parts as properties on the namespace:

tsx
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
DialogPrimitive.Root; // <Dialog.Root>DialogPrimitive.Trigger; // <Dialog.Trigger>DialogPrimitive.Portal; // <Dialog.Portal>DialogPrimitive.Backdrop; // <Dialog.Backdrop>DialogPrimitive.Popup; // <Dialog.Popup>DialogPrimitive.Title; // <Dialog.Title>DialogPrimitive.Description; // <Dialog.Description>DialogPrimitive.Close; // <Dialog.Close>

Accessing TypeScript Types

Props and state types live on the sub-part namespace:

tsx
// Props type for wrapper functionsComponentNamePrimitive.Root.Props;ComponentNamePrimitive.Trigger.Props;ComponentNamePrimitive.Popup.Props;ComponentNamePrimitive.Positioner.Props;
// State type for render functionsComponentNamePrimitive.Root.State;ComponentNamePrimitive.Popup.State;
// Simple components (no sub-parts)ButtonPrimitive.Props;

The render= Prop (NOT asChild)

Base UI uses render= instead of Radix's asChild. Pass a JSX element — Base UI merges all props (handlers, ARIA, refs) onto it:

tsx
// Render trigger as a custom Button<DialogPrimitive.Trigger render={<Button variant="outline" />}>  Open</DialogPrimitive.Trigger>
// Render as a link<ButtonPrimitive render={<a href="/about" />}>About</ButtonPrimitive>
// Render function (for state-dependent rendering)<ComponentNamePrimitive.Thumb  render={(props, state) => (    <span {...props}>      {state.checked ? <CheckIcon /> : <XIcon />}    </span>  )}/>

When render= is passed to Button, set nativeButton={false} to avoid rendering a <button> inside another element:

tsx
function Button({ render, nativeButton, ...props }: ...) {  return (    <ButtonPrimitive      render={render}      nativeButton={nativeButton ?? (render ? false : undefined)}      {...props}    />  )}

Data Attributes (NOT data-state)

Base UI uses boolean data attributes, not Radix-style data-state="open":

Base UI (correct)          Radix (wrong)─────────────────          ──────────────[data-open]                [data-state="open"][data-closed]              [data-state="closed"][data-checked]             [data-state="checked"][data-unchecked]           [data-state="unchecked"][data-disabled]            [data-disabled]  (same)[data-highlighted]         [data-highlighted]  (same)[data-pressed]             [data-state="on"][data-dragging]            (no equivalent)[data-starting-style]      (no equivalent)[data-ending-style]        (no equivalent)

In Tailwind, target these with data-open:, data-closed:, data-checked:, etc.

Overlay Internal Architecture

Every positioned overlay follows the same three-layer structure internally:

Portal (renders at document body, escapes parent overflow/z-index)  └── Positioner (calculates position relative to anchor, receives side/align/offset)        └── Popup (the visible content, receives animation + styling classes)

The Content wrapper you expose to users hides this:

tsx
function PopoverContent({ side, sideOffset, align, alignOffset, className, ...props }) {  return (    <PopoverPrimitive.Portal>      <PopoverPrimitive.Positioner        side={side} sideOffset={sideOffset}        align={align} alignOffset={alignOffset}        className="isolate z-50"      >        <PopoverPrimitive.Popup          data-slot="popover-content"          className={cn(/* styling + animation */, className)}          {...props}        />      </PopoverPrimitive.Positioner>    </PopoverPrimitive.Portal>  )}

Users write <PopoverContent side="top"> — they never see Portal/Positioner/Popup.


5. Props & TypeScript Patterns

Props Type Decision Tree

What type of component?│├── Simple (single element, has CVA)│   → PrimitiveName.Props & VariantProps<typeof variants> & { custom?: type }│├── Simple (single element, no CVA)│   → PrimitiveName.Props & { custom?: type }│├── Compound root (has sub-parts)│   → PrimitiveName.Root.Props & { custom?: type }│├── Overlay content (Popup + Positioner positioning)│   → PrimitiveName.Popup.Props &│     Pick<PrimitiveName.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">│├── Presentational wrapper (plain div)│   → React.ComponentProps<"div">│└── Semantic wrapper (Title, Description)    → PrimitiveName.Title.Props  (or Description.Props, etc.)

Props Destructuring Order

Always destructure in this order:

tsx
function Component({  className,        // 1. className (always first)  variant,          // 2. CVA variant props  size,             // 3. CVA size props  side,             // 4. Positioning props (overlays)  sideOffset,  align,  alignOffset,  isPending,        // 5. Custom boolean state props  children,         // 6. children (only if you need to wrap/modify)  ...props          // 7. Rest spread (always last)}: PropsType) {

Default Values

Set defaults in the destructuring, not in defaultVariants logic:

tsx
// Correct — default in destructuringfunction Component({ variant = "default", size = "default", side = "bottom", sideOffset = 4 }: ...) {
// Wrong — default in bodyfunction Component({ variant, size }: ...) {  const v = variant ?? "default"  // Don't do this

No forwardRef

Base UI handles ref forwarding internally. Never use React.forwardRef:

tsx
// Correctfunction Button({ className, ...props }: ButtonPrimitive.Props) {  return <ButtonPrimitive {...props} />;}
// Wrongconst Button = React.forwardRef<HTMLButtonElement, Props>((props, ref) => {  return <ButtonPrimitive ref={ref} {...props} />;});

6. Styling Patterns

className Merge Order

Always follow this order in cn():

tsx
cn(  // 1. CVA variants (or static base classes)  componentVariants({ variant, size }),
  // 2. Conditional classes (state-based)  isPending && "status-pending",  isActive && "bg-accent",
  // 3. User className (always last — wins over everything via twMerge)  className,);

Standard Base Classes

Every interactive element should include these base utilities:

UtilityPurposeWhen
no-highlightRemove mobile tap highlightAll interactive elements
focus-visible:focus-ringKeyboard focus indicatorButtons, toggles, links, triggers
focus-within:focus-field-ringField focus indicatorForm input wrappers
disabled:status-disabledVisual disabled stateAll interactive elements
motion-reduce:transition-noneRespect reduced motionAny element with transition-*

SVG Icon Normalization

Add to any component that may contain inline SVG icons:

tsx
"[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0";

This ensures icons default to size-4 (unless explicitly sized), can't capture pointer events, and don't shrink in flex layouts.

State Transition Classes

The standard state transition string (for color/background/border changes):

tsx
"transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth";"motion-reduce:transition-none";

Never use transition-all. Enumerate exactly which properties transition.

Token Usage

Background    → bg-surface, bg-overlay, bg-field-background, bg-primary, bg-accentText          → text-foreground, text-muted-foreground, text-primary-foregroundBorder        → border-border, border-field-borderShadow        → shadow-surface, shadow-field, shadow-overlayHover         → hover-only:hover:bg-primary-hover, hover-only:hover:bg-accentPress         → motion-safe:active:scale-[0.97]

Always use hover-only:hover: (never bare hover:) and motion-safe:active: (never bare active:scale-*).

Dark Mode

Never use dark: utility classes. Token values swap automatically via .dark class on <html>. If you need dark-mode-only behavior, use the token system in globals.css.


7. Context Pattern

When to Use Context

Do sub-components need shared information from Root?│├── Size that affects multiple children → Context│   (Switch: size affects Track width + Thumb diameter)│├── Computed values from Root props → Context│   (Slider: value/min/max needed by Indicator for fill %)│├── Just className differences? → No context (pass via cn())│└── Only Root uses the value? → No context (local variable)

Context Implementation Pattern

tsx
// 1. Define type + create context with safe defaulttype ComponentNameContextValue = { size: "sm" | "md" | "lg" }const ComponentNameCtx = React.createContext<ComponentNameContextValue>({ size: "md" })
// 2. Root provides contextfunction ComponentName({ size = "md", children, ...props }: ...) {  return (    <ComponentNameCtx value={{ size }}>      <ComponentNamePrimitive.Root {...props}>        {children}      </ComponentNamePrimitive.Root>    </ComponentNameCtx>  )}
// 3. Sub-components consume contextfunction ComponentNameThumb({ className, ...props }: ...) {  const { size } = React.useContext(ComponentNameCtx)  return (    <ComponentNamePrimitive.Thumb      className={cn(sizeClasses[size], className)}      {...props}    />  )}

Rules:

  • Context always has a default value (never undefined)
  • Use React.createContext at module scope (not inside a function)
  • Use React.useContext in sub-components (not useContext from Root)
  • Name format: {ComponentName}Ctx (short, clear)
  • Value type format: {ComponentName}ContextValue

8. data-slot Convention

Naming Rules

Component TypeRoot data-slotSub-component data-slot
Simple"button"—
Compound root"switch""switch-track", "switch-thumb"
Overlay root"popover""popover-trigger", "popover-content"
Overlay items—"select-item", "select-label"
Presentational—"dialog-header", "dialog-footer"
Pattern: {component-family}-{sub-element}         kebab-case throughout         root gets just the family name

What Gets a data-slot

Does this element render to the DOM?│├── It wraps a Base UI primitive → YES, add data-slot├── It's a presentational <div> (header, footer) → YES, add data-slot├── It's a context provider only → NO (no DOM output)└── It's a Portal (no visible output) → Optional (Dialog does, others don't)

9. Registry & Examples

Registry Entry

Add to apps/docs/registry/registry.ts:

tsx
{  name: "component-name",  type: "registry:ui",  description: "One-line description of the component.",  category: "Actions" | "Forms" | "Overlays" | "Navigation" | "Feedback",  dependencies: ["@base-ui/react"],                    // npm packages  registryDependencies: ["utils"],                     // other registry items  files: [{ path: "ui/component-name.tsx", type: "registry:ui" }],},

Category rules:

CategoryComponents
ActionsButton, Toggle, Toolbar
FormsCheckbox, Combobox, Field, NumberField, RadioGroup, Select, Slider, Switch, TextField
OverlaysDialog, Menu, Popover, Tooltip
NavigationTabs
FeedbackMeter, Progress

Dependencies — when to include:

DependencyWhen
@base-ui/reactAlways (every component wraps a primitive)
class-variance-authorityComponent uses CVA variants
lucide-reactComponent renders Lucide icons internally
@radix-ui/react-iconsComponent renders Radix icons internally
vaulDrawer component only
sonnerToast component only

registryDependencies — reference other registry items by name:

tsx
registryDependencies: ["utils"],                // Almost alwaysregistryDependencies: ["utils", "button"],      // Component renders Button internallyregistryDependencies: ["utils", "field"],       // Form input that depends on Field

Example Files

Location: apps/docs/registry/example/{component-name}/{example-name}.tsx

Naming convention:

  • {component-name}-demo.tsx — Primary demo (required)
  • {component-name}-{variant}.tsx — Variant demos
  • {component-name}-with-{feature}.tsx — Feature showcase

Structure:

tsx
import { ComponentName } from "@/registry/ui/component-name";
export default function ComponentNameDemo() {  return <ComponentName>{/* minimal working example */}</ComponentName>;}

Rules:

  • Always export default function (the registry requires default exports)
  • Import from @/registry/ui/ (not @/components/ui/)
  • Keep examples minimal — demonstrate one concept each
  • Use other Prototyper UI components in examples (Button for triggers, Field for form wrapping)

Build

bash
pnpm registry:build

Generates:

  • public/r/{name}.json — shadcn-compatible install payload
  • public/r/index.json — registry index
  • registry/__registry__.tsx — lazy-loaded example index

10. MDX Documentation Page

Location: apps/docs/content/docs/components/{component-name}.mdx

Template

mdx
---title: ComponentNamedescription: One-line description matching registry entrylinks:  source: registry/ui/component-name.tsx  baseui: https://base-ui.com/react/components/component-name---
<ComponentPreview name="component-name-demo" />
## Installation
<PackageManagers  commands={{    pnpm: "pnpm dlx shadcn@latest add https://prototyper-ui.com/r/component-name.json",    npm: "npx shadcn@latest add https://prototyper-ui.com/r/component-name.json",    yarn: "npx shadcn@latest add https://prototyper-ui.com/r/component-name.json",    bun: "bunx --bun shadcn@latest add https://prototyper-ui.com/r/component-name.json",  }}/>
## Usage
```tsximport { ComponentName } from "@/components/ui/component-name";```
tsx
<ComponentName>Example</ComponentName>

Examples

Variant Name

Styling

Data Slots

Slot nameElement
component-nameRoot element
component-name-trackThe track

API Reference

<TypeTable type={{ variant: { description: "Visual style of the component", type: '"default" | "outline"', default: '"default"', }, }} />

Accessibility

Keyboard interactions, ARIA attributes, screen reader behavior.

```

11. Checklist

Run through this before considering a component complete.

Structure

  • "use client" is line 1
  • Imports follow order: @base-ui/react → CVA → icons → cn → local
  • Deep path Base UI import with alias (as ComponentNamePrimitive)
  • Named exports only (no export default)
  • Single export { } block at end of file

Every Element

  • data-slot attribute on every rendered DOM element
  • className prop accepted and merged via cn() (user class last)
  • No React.forwardRef — Base UI handles refs

Interactivity

  • focus-visible:focus-ring on buttons/toggles/triggers
  • focus-within:focus-field-ring on form input wrappers
  • disabled:status-disabled on disableable elements
  • no-highlight on all interactive elements
  • hover-only:hover: prefix on all hover states (never bare hover:)
  • motion-safe:active:scale-[0.97] for press feedback (never bare active:)

Animation

  • motion-reduce:transition-none alongside every transition-*
  • motion-reduce:animate-none alongside every animate-in/animate-out
  • Overlay animations follow the exact patterns from /animate-ui skill
  • origin-(--transform-origin) on all positioned overlay popups
  • data-entering:will-change-[...] / data-exiting:will-change-[...] on animated overlays
  • Exit animations faster than enter (100ms exit vs 150ms enter)

Tokens

  • Using semantic tokens (bg-primary, shadow-overlay) not raw colors
  • No dark: classes — token swap handles dark mode
  • No transition-all — properties explicitly enumerated
  • No bare shadow-lg/shadow-md — use shadow-surface/shadow-field/shadow-overlay

Registry

  • Entry added to registry/registry.ts with correct dependencies
  • Example created at registry/example/{name}/{name}-demo.tsx
  • pnpm registry:build runs without errors
  • MDX page created at content/docs/components/{name}.mdx
  • Component renders correctly in dev server

TypeScript

  • Props type uses & intersection (Primitive.Props & VariantProps & custom)
  • Overlay Content uses Pick<Positioner.Props, ...> for positioning
  • Default values in destructuring, not in function body
  • No type assertions (as) unless absolutely necessary

12. Common Mistakes

#MistakeFix
1Import from @radix-ui/reactUse @base-ui/react/{component} with alias
2Use asChild propUse render= prop instead
3data-[state=open] selectorsUse data-open: (boolean attributes)
4React.forwardRef wrapperRemove — Base UI handles refs
5export default ComponentUse export { Component } (named only)
6transition-allEnumerate: transition-[color,background-color,...]
7hover:bg-accentUse hover-only:hover:bg-accent
8active:scale-95Use motion-safe:active:scale-[0.97]
9Missing data-slotAdd to every rendered element
10dark:bg-gray-800Use tokens — they swap automatically
11shadow-lgUse shadow-surface / shadow-field / shadow-overlay
12Missing motion-reduce:Add alongside every transition-* and animate-*
13bg-[hsl(var(--primary))]Use bg-primary (OKLCH tokens registered via @theme)
14Barrel import @base-ui/reactDeep path: @base-ui/react/dialog
15Default export in exampleMust be export default function (not export const)
16Context without defaultAlways createContext({ size: "md" }) with safe default
17Positioning on wrong layerside/align/offset go on Positioner, styling on Popup
18Missing isolate z-50 on PositionerRequired for correct stacking

13. Cross-References

  • No Base UI primitive exists: Invoke /build-primitive for ARIA contracts, keyboard navigation, focus management, controlled/uncontrolled state, form integration — building from scratch
  • Animation details: Invoke /animate-ui for exact easing, duration, and per-component overlay classes
  • Component API reference: Invoke /prototyper-ui for design tokens, component selection, and copy-paste usage patterns
  • Base UI primitives: https://base-ui.com/react/components/{name} for primitive API docs