Create Component

Skill

End-to-end guide for adding components to the Prototyper UI library: templates for wrapping Base UI primitives with CVA variants and compound sub-components, plus registry entries, examples and MDX documentation pages.

PrototyperAI/prototyper-ui · apps/docs/skill/create-component7
GitHub에서 보기
README
name: create-component
description: >
  Guide for creating new Prototyper UI components from scratch. Use when building
  a new component, wrapping a Base UI primitive, adding sub-components, setting up
  registry entries, writing examples, or scaffolding MDX documentation pages.
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

"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

"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

"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

"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 1
    import 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 last
    import { 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:

// Correct — deep path, aliased with "Primitive" suffix or component name
import { 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 import
import { 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:

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:

// Props type for wrapper functions
ComponentNamePrimitive.Root.Props;
ComponentNamePrimitive.Trigger.Props;
ComponentNamePrimitive.Popup.Props;
ComponentNamePrimitive.Positioner.Props;

// State type for render functions
ComponentNamePrimitive.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:

// 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:

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:

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:

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:

// Correct — default in destructuring
function Component({ variant = "default", size = "default", side = "bottom", sideOffset = 4 }: ...) {

// Wrong — default in body
function Component({ variant, size }: ...) {
  const v = variant ?? "default"  // Don't do this

No forwardRef

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

// Correct
function Button({ className, ...props }: ButtonPrimitive.Props) {
  return <ButtonPrimitive {...props} />;
}

// Wrong
const Button = React.forwardRef<HTMLButtonElement, Props>((props, ref) => {
  return <ButtonPrimitive ref={ref} {...props} />;
});

6. Styling Patterns

className Merge Order

Always follow this order in cn():

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:

"[&_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):

"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-accent
Text          → text-foreground, text-muted-foreground, text-primary-foreground
Border        → border-border, border-field-border
Shadow        → shadow-surface, shadow-field, shadow-overlay
Hover         → hover-only:hover:bg-primary-hover, hover-only:hover:bg-accent
Press         → 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

// 1. Define type + create context with safe default
type ComponentNameContextValue = { size: "sm" | "md" | "lg" }
const ComponentNameCtx = React.createContext<ComponentNameContextValue>({ size: "md" })

// 2. Root provides context
function ComponentName({ size = "md", children, ...props }: ...) {
  return (
    <ComponentNameCtx value={{ size }}>
      <ComponentNamePrimitive.Root {...props}>
        {children}
      </ComponentNamePrimitive.Root>
    </ComponentNameCtx>
  )
}

// 3. Sub-components consume context
function 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:

{
  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:

registryDependencies: ["utils"],                // Almost always
registryDependencies: ["utils", "button"],      // Component renders Button internally
registryDependencies: ["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:

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

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

---
title: ComponentName
description: One-line description matching registry entry
links:
  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

```tsx
import { ComponentName } from "@/components/ui/component-name";
```
<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