--- name: godui-component-creation description: Create new core components for GodUI (@godui/components) — animated, drop-in replacements for shadcn/ui with strict GPU-only motion. Use when adding a component, fixing missing Tailwind styles on components, wiring Storybook stories, or writing docs pages (main page + required Learn tab) with Workbench/Example and ComponentInstall. --- # GodUI Component Creation GodUI core (`@godui/components`) is **shadcn/ui, animated**: every component is a drop-in replacement for its shadcn new-york-v4 counterpart (same file, exports, props, `data-slot`s, Radix primitives) with motion that runs only on the compositor. Lab — GodUI's expressive, experimental pieces beyond the shadcn catalog — lives in `@godui/lab` (`packages/lab`), maintained as-is with a report-only GPU badge; don't add new components there. ## Quick reference | Step | File | |------|------| | shadcn reference | `node packages/components/scripts/vendor-shadcn.mjs {name}` → `packages/components/test/shadcn/{name}.tsx` (verbatim, never edit) | | Component | `packages/components/src/ui/{name}.tsx` (mirrors shadcn `components/ui/{name}.tsx`) | | Export | `packages/components/src/index.ts` | | Tailwind scan | `packages/components/styles.css` → `@source "./src"` | | Shared motion | `godui-motion` — tokens/keyframes in `packages/components/styles.css`, hooks `src/hooks/use-flip-group.ts` (snap + FLIP) and `src/hooks/use-active-indicator.ts` (sliding indicator), registry item `godui-motion` | | Component-only keyframes | `styles.css` **and** the component's `registry.json` entry (`cssVars.theme` + `css`) | | GPU gate | `packages/components/src/motion-gate/` (runs in `pnpm --filter @godui/components test`) | | Storybook | `apps/storybook/src/stories/ui/{name}.stories.tsx` (title `UI/{Title Case}`, e.g. `UI/Alert Dialog` → id `ui-alert-dialog`) | | Runtime trace | `apps/storybook/motion-trace/{name}.spec.ts` (`traceInteraction` + `expectGpuOnly`) | | Docs | `apps/docs/content/docs/components/{name}/index.mdx` (no category folder) | | Docs demo | `apps/docs/src/components/demos/core/{name}-demo.tsx` (imports `@godui/components`) | | Learn tab (required) | `apps/docs/content/docs/components/{name}/learn.mdx` built from the core kit in `apps/docs/src/components/learn/core/` (`KeyframeScene`, `SpringCurveScene`, `FlipScene`, `AutoPlayScene`, `LiveResult`) — see the `godui-learn-article` skill for LearnPlayer rules | | Nav (main sidebar) | `components/{name}` in the root `apps/docs/content/docs/meta.json` (Components section, alphabetical) and `{name}` in `apps/docs/content/docs/components/meta.json` | | Index card | `apps/docs/content/docs/components/index.mdx` → `` under its group, preview `apps/docs/src/components/card-previews/core/{name}.tsx` (skeleton `Sk`/`Ac`/`Panel`, GPU-only `group-hover` transitions) registered in `card-previews/registry.tsx` | ## 1. Create the component Vendor shadcn's current new-york-v4 source first (`node packages/components/scripts/vendor-shadcn.mjs {name}`), then copy it to `src/ui/{name}.tsx` and change only classes and motion. Keep its public surface **identical**: file name, named exports, props, `data-slot` attributes, the `radix-ui` import, shadcn's own utility classes (including `z-50`, which wins over the z-index scale here — parity first). Add the header comment naming the snapshot. API changes are additive only; an extra DOM element needs its own `data-slot` and is listed as an `extraSlots` entry in the parity test. Imports stay install-shaped — `@/lib/utils`, `@/components/ui/`, `@/hooks/` — never relative; the shadcn CLI rewrites `@/` on install. ```tsx "use client" // GodUI Popover — mirrors shadcn/ui new-york-v4 components/ui/popover.tsx (registry snapshot 2026-10-01). // Motion: scales from the trigger and drifts 4px out of it on a spring; fades. GPU-only. import { Popover as PopoverPrimitive } from "radix-ui" import type * as React from "react" import { cn } from "@/lib/utils" function PopoverContent({ className, align = "center", sideOffset = 4, ...props }: React.ComponentProps) { return ( ) } ``` Add `export * from "./ui/{name}";` to `packages/components/src/index.ts`. Parity test shape (`src/ui/{name}.test.tsx`): ```tsx import * as Shadcn from "../../test/shadcn/popover"; import { expectSlotParity, slotTree } from "../../test/parity"; import * as Godui from "./popover"; function Usage({ ui }: { ui: typeof Shadcn }) { /* shadcn's canonical demo, built from ui.* */ } const { unmount } = render(); const expected = slotTree(); unmount(); render(); // tsc: GodUI props must accept everything shadcn's do expectSlotParity(slotTree(), expected); ``` ## 2. Ensure Tailwind scans component files **This is the most common reason styles don't apply.** `packages/components/styles.css` must include: ```css @import "tailwindcss"; @import "./theme/light.css"; @import "./theme/dark.css"; @source "./src"; ``` Consuming apps also scan explicitly: - `apps/storybook/src/tailwind.css` → `@source "../node_modules/@godui/components/src"` (and `…/@godui/lab/src`) - `apps/docs/src/app/globals.css` → `@source "../../../../packages/components/src"` (and `…/packages/lab/src`) If utilities like `bg-primary` render unstyled, verify `@source "./src"` exists and restart the dev server. Both apps already depend on `@godui/components` via `workspace:*` and it is in the docs `transpilePackages` — no `pnpm install` needed for a new component in the shared package. ## 3. Styling approach — inline Tailwind only **Author all component styles as inline Tailwind utilities in the `.tsx`.** Do **not** create CSS files or add `@layer components` blocks. `styles.css` is the Tailwind **entry only** (`@import`, `@theme`, `@custom-variant`, `@keyframes`). Express CSS-heavy designs (3D buttons, sprite masks, gradients, state machines) with utilities + arbitrary values rather than a stylesheet: - **`group` / `peer`** on the parent + `group-hover:` / `group-focus-visible:` / `group-active:` on children — replaces `.parent:hover .child` descendant selectors. - **`group-data-[variant=default]:` / `data-[status=loading]:`** — replaces `[data-variant="x"]` state selectors. Keep the `data-*` attributes on the element. - **`has-[…]:`, `placeholder:`, `motion-reduce:`, `focus-within:`** — replace `:has()`, `::placeholder`, `prefers-reduced-motion`, `:focus-within`. - **Arbitrary properties** `[background:linear-gradient(...)]`, `[mask-image:var(--mask)]`, `[perspective:800px]`, `[transform:rotateX(35deg)]` — for `color-mix` gradients, sprite masks, 3D. Asset URLs: `import` the asset and pass it through an inline `style={{ "--mask": \`url(\${asset})\` }}` CSS var. - **Size scales** stay token-driven: `px-[var(--button-px-md)] text-[length:var(--button-text-md)]` (the `--button-*` tokens live in `@theme`). Map size → static utility strings in a `Record`. - **Animations** use `animate-` utilities backed by a `--animate-*` token (never a `${var}` nested in an arbitrary value — the scanner can't resolve it). Prefer the shared `animate-godui-*` keyframes from `godui-motion`. A component-only `@keyframes` + token live in **two** spots: `styles.css` (so Storybook/docs render) **and** the component's own `registry.json` entry (`cssVars.theme` token + `css` `@keyframes`). Keep them out of `godui-theme` and `godui-motion`. - **Transitions name compositor properties only** — `transition-opacity`, `transition-transform`, `transition-[translate,scale]`, `transition-[opacity,filter]`. Never bare `transition`, `transition-colors`, `transition-shadow` or `transition-all` (the gate fails them). Only `@keyframes` and the `@theme` token layer belong in CSS. Everything visual is a utility class on the element. ## Motion — strict GPU-only (required, CI-gated) Core components animate **only** `transform` (translate / scale / rotate), `opacity` and `filter`. There is **no allowlist** — the gate in `packages/components/src/motion-gate/` scans every core source file, `styles.css` and every `registry.json` `css` block with `@godui/motion-lint` in strict mode and fails `pnpm test` on anything else. Cheap paint counts too: no `color`/`background-color`/`border-color` transitions, no bare Tailwind `transition`, no `transition-colors`. **CSS-first.** Enter/exit and state motion are CSS keyframes keyed on Radix `data-[state=open|closed]` (and `data-[side=…]`), using the `godui-motion` tokens: | Token | Use | |-------|-----| | `animate-godui-fade-scale-in` / `-out` | popovers, menus, dialogs, tooltips (pair with `origin-(--radix-…-transform-origin)`) | | `animate-godui-slide-in-from-{top,right,bottom,left}` / `slide-out-to-*` | sheets, toasts, side-aware popovers; widen with `[--godui-enter-distance:100%]` for full-panel slides | | `animate-godui-pop` | press feedback | | `ease-spring-snappy` / `ease-spring-smooth` / `ease-spring-bouncy` | CSS `linear()` springs for `transition-*` (`ease-spring-snappy` etc. utilities) | | `ease-out-expo` | exits | | `--godui-duration-fast|base|slow` | 150 / 260 / 380ms, e.g. `duration-(--godui-duration-base)` | **Sizes snap, positions FLIP.** Never animate `height`, `width`, `grid-template-rows` or `max-height`. Let the size change instantly; mark siblings that move with `data-flip` and call `useFlipGroup(containerRef, trigger)` so they glide with an inverse `translate`; fade/slide the revealed content in with a keyframe. **Hover/press feedback.** Color changes snap, or put the hover color on an overlay whose `opacity` transitions. Shadows: a static-shadow layer whose `opacity` animates. Focus rings: a pseudo-element ring animating `opacity` + `scale`, never `ring`/`box-shadow` transitions. **Reduced motion is built into the shared keyframes**: every `godui-*` keyframe multiplies its movement by `--godui-motion`, which only `:root` sets (0 under `prefers-reduced-motion`), so a local `[--godui-enter-distance:100%]` still collapses to a fade; durations shorten and `useFlipGroup` skips. Your own **transform transitions** (switch thumb, tab indicator, hover lift) are not covered — add `motion-reduce:transition-none` / `motion-reduce:translate-none` etc. **Gestures only → `motion` (framer).** Drag-to-dismiss (drawer, toast swipe), carousel drag, slider thumb spring. `animate`/`initial`/`exit`/`while*` objects may contain only transform/opacity/filter keys — the gate scans them. **Prove it at runtime.** Add `apps/storybook/motion-trace/{name}.spec.ts`: ```ts import { type Page, test } from "@playwright/test"; import { expectGpuOnly, traceInteraction } from "./trace"; test("{name} opens on the compositor", async ({ page }) => { const result = await traceInteraction(page, { storyId: "ui-{name}--default", act: async (p: Page) => { await p.getByRole("button", { name: "Open" }).click(); }, windowMs: 600, }); expectGpuOnly(result); }); ``` Run with `pnpm --filter storybook test:motion-trace` (real time only — never `--virtual-time-budget`). ## 4. Storybook story ```typescript import { MyComponent, type MyComponentProps } from "@godui/components"; import type { Meta, StoryObj } from "@storybook/react-vite"; const meta = { title: "UI/MyComponent", component: MyComponent, tags: ["autodocs"], parameters: { layout: "centered" }, } satisfies Meta; export default meta; type Story = StoryObj; export const Primary: Story = { args: { children: "Default", variant: "primary" } satisfies MyComponentProps, }; ``` Include stories for each variant, sizes, and disabled state. ## 5. Docs page Core docs have **no category folder**. Each component is its own folder so the Learn tab can sit beside it: create `apps/docs/content/docs/components/{name}/index.mdx` (the main page) — the Learn tab is `learn.mdx` in the same folder (see §5.5). Component pages are **Workbench-first** and minimal: stage examples as `` tabs, then **only** Installation → Usage → API in the Docs drawer. No lead paragraph, no motion prose. API = one `### ` per exported part (one line naming what it renders / is built on + "accepts all its props", then a `Prop | Type | Default | Description` table of the wrapper's own props/defaults and the key library props; trivial styled parts get the sentence only). Motion docs live on the **Learn tab** (`learn.mdx`), after ``: a **What's animated** table (interaction, keyframe/token, properties, easing, duration) before `## Why GPU-only`, and a closing `## Replacing shadcn` note (same file path, same API, install overwrites `components/ui/{name}.tsx`). ```mdx --- title: My Component description: Short description. workbench: true --- import { MyComponent } from "@godui/components"; import { MyComponentDemo } from "@/components/demos/my-component-demo"; Example; }`} > {/* More variants = more tabs only — never ## headings between them */} ## Installation ## Usage \`\`\`tsx import { MyComponent } from "@/components/ui/my-component"; \`\`\` ## API ### MyComponent Renders a native `
` and accepts all of its props. | Prop | Type | Default | Description | | ---- | ---- | ------- | ----------- | | `variant` | `"primary" \| "secondary"` | `"primary"` | Visual style | ``` Core pages carry **no `date` frontmatter** (no "New" badges in the nav). `Workbench`, `Example`, and `ComponentInstall` are registered globally in `apps/docs/src/components/mdx.tsx`. ### Demo layout kit (`apps/docs/src/components/demos/_kit.tsx`) | Kit | When | Example `fullWidth` | | --- | --- | --- | | `DemoCenter` | Buttons, inputs, small cards | no | | `DemoMedia` | Image compare / accordion / galleries | no | | `DemoScrollPort variant="fill"` | Stage-fill scrubbers (container-scroll, hero-parallax, beam-draw) | **yes** | | `DemoScrollPort variant="framed"` | Mini readers (scroll-progress, scroll-text-reveal, scroll-reveal) | no | | `DemoScene` | Full-bleed scenes (backgrounds, dock, inertia gallery) | **yes** | Do **not** nest `max-w-*` under `fullWidth` unless the demo intentionally builds an inset scene. ### Example content standard (stage tabs) Every component page should feel like it was authored with the same taste — not a mix of “Press me” fillers and full desktop scenes. | Tab | Content | | --- | --- | | **Default** | One intentional product moment. Real verbs / labels (`Get started`, `Hold to delete`, `Email address`). Not a variant matrix. Not “Press/Hover/Push me”. Optional companion CTA only when the component is naturally a pair (e.g. primary + outline). | | **Variants / Masks / …** | Prop galleries. Distinct product labels per item (`Continue` / `Save draft` / `View docs`) — or Short/Medium/Large for sizes. Never make Default *be* this gallery when the tab also exists. | | **Feature tabs** | Only unique behaviors (Loading, Squash, Async, Static Label). Drop tabs that re-show Default. | | **Disabled** | Same label as Default + `disabled`. | **Polish ceiling:** bare control centered on the stage is the default. Reserve `DemoScene` / habitat chrome (wallpaper, menu bars) for environmental components (dock, backgrounds, pointer playgrounds) — don’t under-author buttons to look random, and don’t over-author every control into a fake desktop. **Mobile preview (required check).** The Workbench stage has a desktop/mobile toggle; mobile renders the demo inside a **360px** `