There is no package to install and no version to track. Set up the token layer once, then copy components into your project one file at a time.
Install the dependencies
Motion drives every animation, clsx and tailwind-merge back the cn() helper, and lucide-react draws the one glyph the loaders use — the check when a pass lands.
terminalnpm i motion clsx tailwind-merge lucide-reactAdd the class helper
Every component merges its own classes with whatever you pass in, so this file has to exist before anything else compiles.
src/lib/utils.ts// src/lib/utils.tsimport { clsx, type ClassValue } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}Paste the token layer
Both themes declare the same names, so nothing downstream branches on theme. The loaders read --accent for their signal colour; each also takes its own tint property, so you can retheme one instance without touching the others.
src/app/globals.css/* src/app/globals.css */@import "tailwindcss";@custom-variant dark (&:where(.dark, .dark *)); :root { --bg: oklch(1 0 0); --bg-sub: oklch(0.978 0 0); --fg: oklch(0.16 0 0); --fg-muted: oklch(0.46 0 0); --fg-faint: oklch(0.62 0 0); --line: oklch(0 0 0 / 0.085); --accent: oklch(0.16 0 0); /* the loaders' signal colour */} .dark { --bg: oklch(0.145 0 0); --bg-sub: oklch(0.168 0 0); --fg: oklch(0.95 0 0); --fg-muted: oklch(0.71 0 0); --fg-faint: oklch(0.52 0 0); --line: oklch(1 0 0 / 0.085); --accent: oklch(0.98 0 0);} @theme inline { --color-bg: var(--bg); --color-bg-sub: var(--bg-sub); --color-fg: var(--fg); --color-fg-muted: var(--fg-muted); --color-fg-faint: var(--fg-faint); --color-line: var(--line); --color-accent: var(--accent);} /* Each component names the utilities it needs in its Source line — copy those blocks from globals.css too. */Add the motion presets
Components import these by name. Keeping the numbers in one file is what stops a codebase drifting into forty slightly different springs.
src/lib/motion.ts// src/lib/motion.ts// Named for what they are for, never for their numbers.export const spring = { press: { type: "spring", stiffness: 620, damping: 30, mass: 0.6 }, snappy: { type: "spring", stiffness: 420, damping: 32, mass: 0.7 }, smooth: { type: "spring", stiffness: 260, damping: 30, mass: 0.9 }, liquid: { type: "spring", stiffness: 170, damping: 21, mass: 1.1 }, gentle: { type: "spring", stiffness: 110, damping: 20, mass: 1 },} as const;Copy a component and use it
Each component is a single self-contained file. Leave value off and it runs its own pass; pass your job's progress and it follows. Children are the finished image, revealed when the pass lands.
src/components/result.tsximport { ImageGeneration } from "@/components/ui/image"; export function Result({ job }: { job: GenerationJob }) { return ( <ImageGeneration value={job.status === "queued" ? undefined : job.progress} label={job.stage} onComplete={() => track("image_ready", job.id)} > <img src={job.url} alt={job.prompt} /> </ImageGeneration> );}