diff --git a/AGENTS.md b/AGENTS.md index 9cb76f3..8bd1cfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ Before building a new component, check this list. If it exists, import it. If it | `marquee` | `components/motion/marquee.tsx` | Infinite horizontal or vertical scroll, pause-on-hover | | `tabs` | `components/motion/tabs.tsx` | Pill, segment or underline tabs with spring layoutId indicator | | `switch` | `components/motion/switch.tsx` | Toggle with spring-driven thumb and press feedback | +| `select` | `components/motion/select.tsx` | Composable select primitives (`Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`); panel bouncily unfolds out of the trigger and separates, with staggered items | | `range-slider` | `components/motion/range-slider.tsx` | Range slider (`RangeSlider`) with tick dots and a bouncy vertical-bar thumb that glides between snapped steps; drag + keyboard, controlled/uncontrolled | | `bottom-sheet` | `components/motion/bottom-sheet.tsx` | Draggable bottom sheet with snap points, inertia and glass surface | | `shared-layout-bg` | `components/motion/shared-layout-bg.tsx` | Pill that glides between hovered items via shared layout | diff --git a/app/playground/page.tsx b/app/playground/page.tsx new file mode 100644 index 0000000..92a9b73 --- /dev/null +++ b/app/playground/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { Playground } from "@/components/app/playground/playground"; + +export const metadata: Metadata = { + title: "Playground", + description: + "Tweak spring, tween and stagger properties, watch them play, and copy the motion code. Built on beUI's motion tokens.", +}; + +export default function PlaygroundPage() { + return ; +} diff --git a/components/app/mobile-nav.tsx b/components/app/mobile-nav.tsx index 34ea757..c2f6c7f 100644 --- a/components/app/mobile-nav.tsx +++ b/components/app/mobile-nav.tsx @@ -1,12 +1,12 @@ "use client"; -import Link from "next/link"; import { Menu } from "lucide-react"; +import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; +import { SidebarNav } from "@/components/app/site-sidebar"; import { BottomSheet } from "@/components/motion/bottom-sheet"; import { Button } from "@/components/motion/button"; -import { SidebarNav } from "@/components/app/site-sidebar"; import { cn } from "@/lib/utils"; /** Mobile nav: a header hamburger that opens the sidebar list in beUI's own bottom sheet. */ @@ -64,6 +64,18 @@ export function MobileNav() { > Blocks + setOpen(false)} + className={cn( + "rounded-md px-3 py-1.5 text-sm transition-colors", + pathname.startsWith("/playground") + ? "text-foreground" + : "text-muted-foreground hover:text-foreground", + )} + > + Playground + setOpen(false)} /> diff --git a/components/app/playground/code-panel.tsx b/components/app/playground/code-panel.tsx new file mode 100644 index 0000000..fa72e49 --- /dev/null +++ b/components/app/playground/code-panel.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getSingletonHighlighter, type Highlighter } from "shiki"; +import { CopyButton } from "@/components/app/copy-button"; +import { cn } from "@/lib/utils"; + +// One shared highlighter for the page, created lazily on first render. +let hlPromise: Promise | null = null; +function highlighter() { + if (!hlPromise) { + hlPromise = getSingletonHighlighter({ + themes: ["github-light", "github-dark"], + langs: ["tsx"], + }); + } + return hlPromise; +} + +export function CodePanel({ + code, + className, +}: { + code: string; + className?: string; +}) { + const [html, setHtml] = useState(null); + + useEffect(() => { + let cancelled = false; + highlighter().then((hl) => { + if (cancelled) return; + setHtml( + hl.codeToHtml(code, { + lang: "tsx", + themes: { light: "github-light", dark: "github-dark" }, + defaultColor: false, + }), + ); + }); + return () => { + cancelled = true; + }; + }, [code]); + + return ( +
+
+ +
+ + {html ? ( +
+ ) : ( + // pre-highlight fallback keeps layout stable on first paint +
+          {code}
+        
+ )} +
+ ); +} diff --git a/components/app/playground/controls.tsx b/components/app/playground/controls.tsx new file mode 100644 index 0000000..a0a472f --- /dev/null +++ b/components/app/playground/controls.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { Plus, X } from "lucide-react"; +import type { ReactNode } from "react"; +import { RangeSlider } from "@/components/motion/range-slider"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/motion/select"; +import type { ControlDef, ControlValue, Values } from "./core"; +import { CurveEditor } from "./curve-editor"; + +/** Decimal places implied by a step (0.05 -> 2, 1 -> 0). */ +function decimals(step: number) { + const s = String(step); + return s.includes(".") ? s.split(".")[1].length : 0; +} + +export function Controls({ + controls, + values, + onChange, +}: { + controls: ControlDef[]; + values: Values; + onChange: (key: string, value: ControlValue) => void; +}) { + return ( +
+ {controls.map((c) => { + if (c.kind === "slider") { + const v = typeof values[c.key] === "number" ? (values[c.key] as number) : c.min; + return ( + // not a
+ ); +} + +function Hint({ children }: { children: ReactNode }) { + return ( +

{children}

+ ); +} diff --git a/components/app/playground/core.ts b/components/app/playground/core.ts new file mode 100644 index 0000000..1b7e61c --- /dev/null +++ b/components/app/playground/core.ts @@ -0,0 +1,104 @@ +import type { FC } from "react"; + +/** A single control's current value. */ +export type ControlValue = number | string | number[]; + +/** All control values for one playground type, keyed by control `key`. */ +export type Values = Record; + +/** + * A control rendered in the playground's left panel. Each type declares its own + * set; the shell renders them generically and never knows what they drive. + */ +export type ControlDef = + | { + kind: "slider"; + key: string; + label: string; + /** One-line plain-English definition shown under the control. */ + hint?: string; + min: number; + max: number; + step: number; + unit?: string; + } + | { + kind: "select"; + key: string; + label: string; + hint?: string; + options: { label: string; value: string }[]; + } + | { + kind: "numberlist"; + key: string; + label: string; + hint?: string; + minItems?: number; + maxItems?: number; + /** Slider bounds for each value; may depend on other controls. */ + bounds?: (values: Values) => { min: number; max: number; step: number }; + /** Plain-English meaning of a single value (e.g. "150% size, bigger"). */ + describe?: (n: number, values: Values) => string; + } + | { kind: "curve"; key: string; label: string; hint?: string }; + +export interface Preset { + name: string; + values: Values; +} + +/** + * One decoded line of the generated code: `code` is the snippet fragment it + * refers to (rendered monospace), `text` explains it in plain English using the + * current values. This is the teaching layer — it turns the code into a lesson. + */ +export interface ExplainPoint { + code: string; + text: string; +} + +/** + * A self-contained playground type (spring, tween, stagger...). Adding one is a + * single module plus a line in `items/index.ts` — the shell stays untouched. + */ +export interface PlaygroundItem { + slug: string; + label: string; + blurb: string; + /** Phase-2 types render in the sidebar as disabled "soon" rows. */ + comingSoon?: boolean; + controls: ControlDef[]; + defaults: Values; + presets: Preset[]; + /** The primitive subject that visualizes the current values. */ + Preview: FC<{ values: Values; replayKey: number }>; + /** Copy-paste `motion/react` snippet for the current values. */ + toCode: (values: Values) => string; + /** Plain-English decode of the current code, for first-time learners. */ + explain: (values: Values) => ExplainPoint[]; + /** + * Optional: adjust dependent controls when one changes (e.g. reset the + * keyframe list when the animated property switches). Receives the changed + * key and the already-updated values; returns the final values to store. + */ + coerce?: (key: string, next: Values) => Values; +} + +/** Read a numeric control value with a fallback. */ +export function num(values: Values, key: string, fallback = 0): number { + const v = values[key]; + return typeof v === "number" ? v : fallback; +} + +/** Read a string control value with a fallback. */ +export function str(values: Values, key: string, fallback = ""): string { + const v = values[key]; + return typeof v === "string" ? v : fallback; +} + +/** Read a number[] control value (e.g. a cubic-bezier) with a fallback. */ +export function arr(values: Values, key: string, fallback: number[]): number[] { + const v = values[key]; + return Array.isArray(v) ? v : fallback; +} diff --git a/components/app/playground/curve-editor.tsx b/components/app/playground/curve-editor.tsx new file mode 100644 index 0000000..50524a1 --- /dev/null +++ b/components/app/playground/curve-editor.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useRef, useState } from "react"; + +const SIZE = 200; +const PAD = 34; // room for overshoot handles past the 0..1 core +const CORE = SIZE - PAD * 2; +// y domain allows overshoot beyond [0,1] (e.g. EASE_OUT peaks at y=1). +const Y_MIN = -0.4; +const Y_MAX = 1.4; + +const clamp = (n: number, lo: number, hi: number) => + Math.min(hi, Math.max(lo, n)); + +// domain (0..1 x, Y_MIN..Y_MAX y) -> svg pixels +const px = (x: number) => PAD + x * CORE; +const py = (y: number) => PAD + (Y_MAX - y) * (CORE / (Y_MAX - Y_MIN)); +// svg pixels -> domain +const toX = (p: number) => clamp((p - PAD) / CORE, 0, 1); +const toY = (p: number) => + clamp(Y_MAX - (p - PAD) / (CORE / (Y_MAX - Y_MIN)), Y_MIN, Y_MAX); + +export function CurveEditor({ + value, + onChange, +}: { + value: number[]; + onChange: (next: number[]) => void; +}) { + const ref = useRef(null); + const [drag, setDrag] = useState<0 | 1 | null>(null); + const [x1, y1, x2, y2] = value; + + const move = (e: React.PointerEvent, handle: 0 | 1) => { + const svg = ref.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const x = toX(((e.clientX - rect.left) / rect.width) * SIZE); + const y = toY(((e.clientY - rect.top) / rect.height) * SIZE); + onChange(handle === 0 ? [x, y, x2, y2] : [x1, y1, x, y]); + }; + + const start = px(0); + const startY = py(0); + const end = px(1); + const endY = py(1); + const c1x = px(x1); + const c1y = py(y1); + const c2x = px(x2); + const c2y = py(y2); + + return ( + drag !== null && move(e, drag)} + onPointerUp={() => setDrag(null)} + onPointerLeave={() => setDrag(null)} + aria-label="Easing curve editor" + > + {/* baseline 0 and 1 guides */} + + + + {/* handle leashes */} + + + + {/* the curve */} + + + {/* fixed endpoints */} + + + + {/* draggable control points */} + {([0, 1] as const).map((h) => ( + { + (e.target as SVGElement).setPointerCapture(e.pointerId); + setDrag(h); + }} + /> + ))} + + ); +} diff --git a/components/app/playground/items/gestures.tsx b/components/app/playground/items/gestures.tsx new file mode 100644 index 0000000..1f649be --- /dev/null +++ b/components/app/playground/items/gestures.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import { useRef } from "react"; +import { num, type PlaygroundItem, str, type Values } from "../core"; + +function GesturesPreview({ + values, + replayKey, +}: { + values: Values; + replayKey: number; +}) { + const reduce = useReducedMotion(); + const ref = useRef(null); + const hover = num(values, "hover", 1.2); + const tap = num(values, "tap", 0.9); + const mode = str(values, "drag", "x"); + const drag = mode === "off" ? false : mode === "free" ? true : (mode as "x" | "y"); + + return ( +
+ + drag + +
+ ); +} + +export const gesturesItem: PlaygroundItem = { + slug: "gestures", + label: "Gestures", + blurb: + "Motion responds to pointer input. whileHover and whileTap animate to a state during the gesture; drag makes an element throwable.", + controls: [ + { + kind: "slider", + key: "hover", + label: "Hover scale", + hint: "Size while the pointer is over it.", + min: 1, + max: 1.6, + step: 0.05, + }, + { + kind: "slider", + key: "tap", + label: "Tap scale", + hint: "Size while pressed. Under 1 = a press-in.", + min: 0.6, + max: 1, + step: 0.05, + }, + { + kind: "select", + key: "drag", + label: "Drag", + hint: "Constrain dragging to an axis, free, or off.", + options: [ + { label: "Off", value: "off" }, + { label: "X only", value: "x" }, + { label: "Y only", value: "y" }, + { label: "Free", value: "free" }, + ], + }, + ], + defaults: { hover: 1.2, tap: 0.9, drag: "x" }, + presets: [ + { name: "Subtle", values: { hover: 1.1, tap: 0.95 } }, + { name: "Pop", values: { hover: 1.4, tap: 0.85 } }, + ], + Preview: GesturesPreview, + explain: (v) => { + const hover = num(v, "hover", 1.2); + const tap = num(v, "tap", 0.9); + const mode = str(v, "drag", "x"); + return [ + { + code: `whileHover={{ scale: ${hover} }}`, + text: `While the pointer is over the box, animate to ${hover}× size (${Math.round(hover * 100)}%). It springs back to normal when the pointer leaves — no state or handlers needed.`, + }, + { + code: `whileTap={{ scale: ${tap} }}`, + text: `While it's being pressed, go to ${tap}× size. ${tap < 1 ? "Under 1 = a press-in, the tactile feel of a real button." : "At 1 it stays put on press."}`, + }, + { + code: mode === "off" ? "drag={false}" : mode === "free" ? "drag" : `drag="${mode}"`, + text: + mode === "off" + ? "Dragging is off." + : `Makes the box draggable${mode === "free" ? " in any direction" : ` on the ${mode}-axis only`}. dragConstraints keeps it inside its parent, and it springs back if thrown past the edge.`, + }, + ]; + }, + toCode: (v) => { + const mode = str(v, "drag", "x"); + const dragAttr = + mode === "off" + ? "" + : mode === "free" + ? "\n drag" + : `\n drag="${mode}"`; + return `import { motion } from "motion/react"; + +export function Demo() { + return ( + + ); +}`; + }, +}; diff --git a/components/app/playground/items/index.ts b/components/app/playground/items/index.ts new file mode 100644 index 0000000..d9bafdf --- /dev/null +++ b/components/app/playground/items/index.ts @@ -0,0 +1,24 @@ +import type { PlaygroundItem } from "../core"; +import { gesturesItem } from "./gestures"; +import { keyframesItem } from "./keyframes"; +import { layoutItem } from "./layout"; +import { scrollItem } from "./scroll"; +import { springItem } from "./spring"; +import { staggerItem } from "./stagger"; +import { tweenItem } from "./tween"; + +/** + * Local playground catalog — NOT the shadcn registry (`lib/registry.ts`). These + * never ship as installable components. + */ +export const PLAYGROUND_ITEMS: PlaygroundItem[] = [ + springItem, + tweenItem, + staggerItem, + keyframesItem, + gesturesItem, + layoutItem, + scrollItem, +]; + +export const PLAYGROUND_SOON: { slug: string; label: string }[] = []; diff --git a/components/app/playground/items/keyframes.tsx b/components/app/playground/items/keyframes.tsx new file mode 100644 index 0000000..4424c6c --- /dev/null +++ b/components/app/playground/items/keyframes.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { motion, type Transition, useReducedMotion } from "motion/react"; +import { arr, num, type PlaygroundItem, str, type Values } from "../core"; + +// Sensible starting values per property — used as defaults and when the +// animated property switches (so a scale list doesn't linger on a rotate). +const SEQ: Record = { + x: [0, 150, 40, 150, 0], + scale: [1, 1.5, 0.7, 1.2, 1], + rotate: [0, 90, 180, 270, 360], + opacity: [1, 0.2, 1, 0.4, 1], +}; + +// Slider range + step for each property's checkpoint values. +const BOUNDS: Record = { + x: { min: -200, max: 200, step: 5 }, + scale: { min: 0, max: 2, step: 0.05 }, + rotate: { min: -360, max: 360, step: 5 }, + opacity: { min: 0, max: 1, step: 0.05 }, +}; + +// What a single value means, in plain English, for the chosen property. +function describeValue(n: number, prop: string) { + if (prop === "scale") + return `${Math.round(n * 100)}% size${n > 1 ? ", bigger" : n < 1 ? ", smaller" : ", normal"}`; + if (prop === "opacity") + return `${Math.round(n * 100)}% visible${n === 0 ? ", invisible" : n === 1 ? ", solid" : ""}`; + if (prop === "rotate") + return `rotated ${n}°${n === 0 ? ", upright" : ""}`; + return `${n}px ${n > 0 ? "right of" : n < 0 ? "left of" : "at"} start`; +} + +function repeatOf(rep: string): Transition { + if (rep === "loop") return { repeat: Number.POSITIVE_INFINITY, repeatType: "loop" }; + if (rep === "mirror") return { repeat: Number.POSITIVE_INFINITY, repeatType: "mirror" }; + return { repeat: 0 }; +} + +function KeyframesPreview({ + values, + replayKey, +}: { + values: Values; + replayKey: number; +}) { + const reduce = useReducedMotion(); + const prop = str(values, "property", "scale"); + const dur = num(values, "duration", 2); + const rep = str(values, "repeat", "loop"); + const frames = arr(values, "frames", SEQ.scale); + + return ( +
+ +
+ ); +} + +function repeatCode(rep: string) { + if (rep === "loop") return ",\n repeat: Infinity"; + if (rep === "mirror") + return ',\n repeat: Infinity,\n repeatType: "mirror"'; + return ""; +} + +export const keyframesItem: PlaygroundItem = { + slug: "keyframes", + label: "Keyframes", + blurb: + "A property steps through a list of values in order, evenly spaced across the duration. Edit the list to change where it goes.", + controls: [ + { + kind: "select", + key: "property", + label: "Property", + hint: "Which property the keyframes drive. Switching it resets the values.", + options: [ + { label: "x", value: "x" }, + { label: "scale", value: "scale" }, + { label: "rotate", value: "rotate" }, + { label: "opacity", value: "opacity" }, + ], + }, + { + kind: "numberlist", + key: "frames", + label: "Keyframe values", + hint: "Each slider is a checkpoint the box passes through, in order. Drag one, or add/remove checkpoints.", + minItems: 2, + maxItems: 8, + bounds: (v) => BOUNDS[str(v, "property", "scale")] ?? BOUNDS.scale, + describe: (n, v) => describeValue(n, str(v, "property", "scale")), + }, + { + kind: "slider", + key: "duration", + label: "Duration", + hint: "Time for one pass through all the values.", + min: 0.4, + max: 4, + step: 0.1, + unit: "s", + }, + { + kind: "select", + key: "repeat", + label: "Repeat", + hint: "Once, loop from the start, or mirror back and forth.", + options: [ + { label: "Once", value: "once" }, + { label: "Loop", value: "loop" }, + { label: "Mirror", value: "mirror" }, + ], + }, + ], + defaults: { property: "scale", frames: SEQ.scale, duration: 2, repeat: "loop" }, + // switching property loads that property's starting checkpoints + coerce: (key, next) => + key === "property" + ? { ...next, frames: SEQ[str(next, "property", "scale")] ?? SEQ.scale } + : next, + presets: [ + { name: "Pulse", values: { property: "scale", frames: [1, 1.4, 1], duration: 1.4, repeat: "mirror" } }, + { name: "Spin", values: { property: "rotate", frames: [0, 360], duration: 2, repeat: "loop" } }, + { name: "Blink", values: { property: "opacity", frames: [1, 0.2, 1], duration: 1.2, repeat: "mirror" } }, + ], + Preview: KeyframesPreview, + explain: (v) => { + const prop = str(v, "property", "scale"); + const dur = num(v, "duration", 2); + const rep = str(v, "repeat", "loop"); + const seq = arr(v, "frames", SEQ.scale); + + const read = (n: number) => { + if (prop === "scale") + return `${n} (${Math.round(n * 100)}% size${n > 1 ? ", bigger" : n < 1 ? ", smaller" : ", normal"})`; + if (prop === "opacity") return `${n} (${Math.round(n * 100)}% visible)`; + if (prop === "rotate") return `${n}°`; + return `${n}px`; + }; + + return [ + { + code: `${prop}: [${seq.join(", ")}]`, + text: `This array is a list of values the box passes through, in order: ${seq.map(read).join(" → ")}. It's not "from A to B" — it hits every value as a checkpoint.`, + }, + { + code: `${seq.length} keyframes over ${dur}s`, + text: `The values are spaced evenly across the duration. ${seq.length} values over ${dur}s means each leg takes about ${seq.length > 1 ? +(dur / (seq.length - 1)).toFixed(2) : dur}s. Add or remove values to change the path.`, + }, + { + code: `repeat: ${rep}`, + text: + rep === "loop" + ? "Loop: when it reaches the last value it jumps back to the first and runs again — so it can snap at the seam." + : rep === "mirror" + ? "Mirror: it plays forward, then backward, then forward — smooth, no snap, like a pulse." + : "Once: it runs a single time and stops on the last value.", + }, + ]; + }, + toCode: (v) => { + const prop = str(v, "property", "scale"); + const seq = arr(v, "frames", SEQ.scale); + return `import { motion } from "motion/react"; + +export function Demo() { + return ( + + ); +}`; + }, +}; diff --git a/components/app/playground/items/layout.tsx b/components/app/playground/items/layout.tsx new file mode 100644 index 0000000..f3ce588 --- /dev/null +++ b/components/app/playground/items/layout.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { motion, type Transition } from "motion/react"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { type PlaygroundItem, str, type Values } from "../core"; + +const FEEL: Record = { + Snappy: { type: "spring", stiffness: 500, damping: 30 }, + Smooth: { type: "spring", stiffness: 300, damping: 30 }, + Slow: { type: "tween", duration: 0.5, ease: [0.16, 1, 0.3, 1] }, +}; + +function LayoutPreview({ values }: { values: Values; replayKey: number }) { + const feel = FEEL[str(values, "feel", "Snappy")] ?? FEEL.Snappy; + const [active, setActive] = useState(0); + + return ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+

click a tab

+
+ ); +} + +export const layoutItem: PlaygroundItem = { + slug: "layout", + label: "Layout", + blurb: + "A shared layoutId morphs one element into another when which one is mounted changes, so an indicator glides between positions instead of jumping.", + controls: [ + { + kind: "select", + key: "feel", + label: "Feel", + hint: "The transition the pill travels with.", + options: [ + { label: "Snappy", value: "Snappy" }, + { label: "Smooth", value: "Smooth" }, + { label: "Slow", value: "Slow" }, + ], + }, + ], + defaults: { feel: "Snappy" }, + presets: [], + Preview: LayoutPreview, + explain: (v) => { + const feel = str(v, "feel", "Snappy"); + return [ + { + code: 'layoutId="pill"', + text: "The key idea. Only one pill is rendered — under the active tab. When you click another tab, React unmounts it here and mounts it there, but the shared layoutId tells Motion they're the same element, so it animates between the two positions instead of popping.", + }, + { + code: "active === i && ", + text: "The pill only exists inside the active tab. You don't compute any positions or offsets yourself — Motion measures the old and new spots and tweens the gap automatically.", + }, + { + code: `transition (${feel})`, + text: `Controls how the pill travels. ${feel === "Slow" ? "A timed tween — same duration every move." : `A spring — ${feel === "Snappy" ? "stiff, so it snaps over quickly" : "softer, so it glides"}.`}`, + }, + ]; + }, + toCode: (v) => { + const feel = str(v, "feel", "Snappy"); + const t = + feel === "Slow" + ? '{ type: "tween", duration: 0.5, ease: [0.16, 1, 0.3, 1] }' + : feel === "Smooth" + ? '{ type: "spring", stiffness: 300, damping: 30 }' + : '{ type: "spring", stiffness: 500, damping: 30 }'; + return `import { motion } from "motion/react"; + +export function Tabs({ active, setActive }) { + return tabs.map((tab, i) => ( + + )); +}`; + }, +}; diff --git a/components/app/playground/items/scroll.tsx b/components/app/playground/items/scroll.tsx new file mode 100644 index 0000000..7308219 --- /dev/null +++ b/components/app/playground/items/scroll.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { motion, useReducedMotion, useScroll, useTransform } from "motion/react"; +import { useRef } from "react"; +import { type PlaygroundItem, str, type Values } from "../core"; + +function ScrollPreview({ values }: { values: Values; replayKey: number }) { + const reduce = useReducedMotion(); + const ref = useRef(null); + const { scrollYProgress } = useScroll({ container: ref }); + const effect = str(values, "effect", "fade"); + + // all mapped unconditionally (hooks rule); only the picked one is applied + const opacity = useTransform(scrollYProgress, [0, 1], [0.15, 1]); + const scale = useTransform(scrollYProgress, [0, 1], [0.6, 1]); + const x = useTransform(scrollYProgress, [0, 1], [-70, 0]); + const rotate = useTransform(scrollYProgress, [0, 1], [0, 180]); + + const style = reduce + ? undefined + : effect === "scale" + ? { scale } + : effect === "slide" + ? { x } + : effect === "rotate" + ? { rotate } + : { opacity }; + + return ( +
+
+
+

scroll down ↓

+
+ +
+
+
+
+ ); +} + +export const scrollItem: PlaygroundItem = { + slug: "scroll", + label: "Scroll", + blurb: + "Scroll-linked motion ties a value to scroll position. useScroll reports 0→1 progress; useTransform maps that range onto a transform.", + controls: [ + { + kind: "select", + key: "effect", + label: "Effect", + hint: "What the box does as the panel scrolls.", + options: [ + { label: "Fade", value: "fade" }, + { label: "Scale", value: "scale" }, + { label: "Slide", value: "slide" }, + { label: "Rotate", value: "rotate" }, + ], + }, + ], + defaults: { effect: "fade" }, + presets: [], + Preview: ScrollPreview, + explain: (v) => { + const effect = str(v, "effect", "fade"); + const map: Record = { + fade: { prop: "opacity", range: "[0.15, 1]", start: "15% visible", end: "fully visible" }, + scale: { prop: "scale", range: "[0.6, 1]", start: "60% size", end: "full size" }, + slide: { prop: "x", range: "[-70, 0]", start: "70px left", end: "in place" }, + rotate: { prop: "rotate", range: "[0, 180]", start: "0°", end: "180°" }, + }; + const m = map[effect] ?? map.fade; + return [ + { + code: "useScroll({ container: ref })", + text: "Watches how far the panel is scrolled and gives back scrollYProgress: a number from 0 (scrolled to top) to 1 (scrolled to bottom). It updates on every scroll frame.", + }, + { + code: `useTransform(p, [0, 1], ${m.range})`, + text: `Remaps that 0→1 progress onto a real value range. As progress goes 0→1, ${m.prop} goes ${m.range.replace(/[[\]]/g, "")} — so at the top it's ${m.start}, at the bottom ${m.end}.`, + }, + { + code: `style={{ ${m.prop} }}`, + text: "The mapped value is a live motion value wired straight into the box's style, so it tracks your scroll exactly — scroll up and it runs in reverse.", + }, + ]; + }, + toCode: (v) => { + const effect = str(v, "effect", "fade"); + const map: Record = { + fade: { prop: "opacity", range: "[0.15, 1]" }, + scale: { prop: "scale", range: "[0.6, 1]" }, + slide: { prop: "x", range: "[-70, 0]" }, + rotate: { prop: "rotate", range: "[0, 180]" }, + }; + const m = map[effect] ?? map.fade; + return `import { useRef } from "react"; +import { motion, useScroll, useTransform } from "motion/react"; + +export function Demo() { + const ref = useRef(null); + const { scrollYProgress } = useScroll({ container: ref }); + const ${m.prop} = useTransform(scrollYProgress, [0, 1], ${m.range}); + + return ( +
+ +
+ ); +}`; + }, +}; diff --git a/components/app/playground/items/spring.tsx b/components/app/playground/items/spring.tsx new file mode 100644 index 0000000..befa4e9 --- /dev/null +++ b/components/app/playground/items/spring.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useReducedMotion } from "motion/react"; +import { num, type PlaygroundItem, type Values } from "../core"; +import { TravelPreview } from "./travel-preview"; + +function SpringPreview({ + values, + replayKey, +}: { + values: Values; + replayKey: number; +}) { + const reduce = useReducedMotion(); + return ( + + ); +} + +export const springItem: PlaygroundItem = { + slug: "spring", + label: "Spring", + blurb: + "Physics-based motion. Stiffness pulls toward the target, damping resists, mass adds weight. Lower damping overshoots.", + controls: [ + { + kind: "slider", + key: "stiffness", + label: "Stiffness", + hint: "Pull toward the target. Higher = faster, snappier.", + min: 1, + max: 1000, + step: 1, + }, + { + kind: "slider", + key: "damping", + label: "Damping", + hint: "Resistance / friction. Lower = more bounce and overshoot.", + min: 1, + max: 100, + step: 1, + }, + { + kind: "slider", + key: "mass", + label: "Mass", + hint: "Weight of the object. Heavier = slower, more sluggish.", + min: 0.1, + max: 5, + step: 0.1, + }, + ], + defaults: { stiffness: 500, damping: 30, mass: 0.6 }, + presets: [ + { name: "Gentle", values: { stiffness: 120, damping: 14, mass: 1 } }, + { name: "Wobbly", values: { stiffness: 180, damping: 8, mass: 1 } }, + { name: "Stiff", values: { stiffness: 700, damping: 40, mass: 0.8 } }, + { name: "Slow", values: { stiffness: 80, damping: 20, mass: 1.5 } }, + ], + Preview: SpringPreview, + explain: (v) => { + const s = num(v, "stiffness", 500); + const d = num(v, "damping", 30); + const m = num(v, "mass", 0.6); + return [ + { + code: 'type: "spring"', + text: "No duration here. Instead of taking a fixed time, the box is pulled to the target like it's on a real spring. The three numbers below decide how that feels.", + }, + { + code: `stiffness: ${s}`, + text: `How hard the spring pulls. ${s} is ${s < 200 ? "soft, so it moves slowly" : s < 500 ? "medium" : "strong, so it snaps fast"}. Higher = faster, more urgent.`, + }, + { + code: `damping: ${d}`, + text: `Friction that slows the spring down. ${d < 18 ? `${d} is low, so the box overshoots the target and bounces before settling.` : d < 35 ? `${d} gives a little overshoot, then settles.` : `${d} is high, so it eases in with no bounce.`}`, + }, + { + code: `mass: ${m}`, + text: `The box's weight. ${m < 0.6 ? `${m} is light, so it reacts quickly.` : m > 1.2 ? `${m} is heavy, so it feels sluggish and slow to start.` : `${m} is medium weight.`} Heavier = slower, more inertia.`, + }, + ]; + }, + toCode: (v) => `import { motion } from "motion/react"; + +export function Demo() { + return ( + + ); +}`, +}; diff --git a/components/app/playground/items/stagger.tsx b/components/app/playground/items/stagger.tsx new file mode 100644 index 0000000..2420daa --- /dev/null +++ b/components/app/playground/items/stagger.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import { num, type PlaygroundItem, str, type Values } from "../core"; + +function StaggerPreview({ + values, + replayKey, +}: { + values: Values; + replayKey: number; +}) { + const reduce = useReducedMotion(); + const count = Math.round(num(values, "count", 6)); + const stagger = num(values, "stagger", 0.06); + const delayChildren = num(values, "delayChildren", 0); + const direction = str(values, "direction", "forward") === "reverse" ? -1 : 1; + + return ( +
+ + {Array.from({ length: count }, (_, i) => ( + + ))} + +
+ ); +} + +export const staggerItem: PlaygroundItem = { + slug: "stagger", + label: "Stagger", + blurb: + "Children animate in sequence. Stagger sets the gap between each; delay holds the whole group before it starts.", + controls: [ + { + kind: "slider", + key: "count", + label: "Children", + hint: "How many items animate in.", + min: 3, + max: 12, + step: 1, + }, + { + kind: "slider", + key: "stagger", + label: "Stagger", + hint: "Gap between each child starting. Higher = more of a wave.", + min: 0, + max: 0.2, + step: 0.01, + unit: "s", + }, + { + kind: "slider", + key: "delayChildren", + label: "Delay", + hint: "Pause before the whole group begins.", + min: 0, + max: 0.6, + step: 0.05, + unit: "s", + }, + { + kind: "select", + key: "direction", + label: "Direction", + hint: "Order children fire in.", + options: [ + { label: "Forward", value: "forward" }, + { label: "Reverse", value: "reverse" }, + ], + }, + ], + defaults: { count: 6, stagger: 0.06, delayChildren: 0, direction: "forward" }, + presets: [ + { name: "Tight", values: { stagger: 0.03, delayChildren: 0 } }, + { name: "Relaxed", values: { stagger: 0.08, delayChildren: 0.1 } }, + { name: "Dramatic", values: { stagger: 0.14, delayChildren: 0.2 } }, + ], + Preview: StaggerPreview, + explain: (v) => { + const count = Math.round(num(v, "count", 6)); + const st = num(v, "stagger", 0.06); + const dc = num(v, "delayChildren", 0); + const dir = str(v, "direction", "forward"); + const last = +(dc + (count - 1) * st).toFixed(2); + return [ + { + code: "variants={ hidden, show }", + text: 'Two named states. "hidden" is the start (invisible, small, nudged down), "show" is the end. Every child animates from hidden to show — you describe states, not steps.', + }, + { + code: `staggerChildren: ${st}`, + text: `The gap between each child starting, in seconds. They don't all fire at once — child 2 waits ${st}s after child 1, and so on, making the wave.`, + }, + { + code: `delayChildren: ${dc}`, + text: `How long the whole group waits before the first child starts. ${dc === 0 ? "0 = starts immediately." : `Here, ${dc}s of dead time up front.`}`, + }, + { + code: `${count} items, ${dir}`, + text: `With ${count} children at ${st}s apart${dc ? ` after a ${dc}s delay` : ""}, the last one begins ${last}s in. ${dir === "reverse" ? "Reverse means the last child fires first." : "Forward fires them first-to-last."}`, + }, + ]; + }, + toCode: (v) => { + const dir = str(v, "direction", "forward") === "reverse" ? -1 : 1; + return `import { motion } from "motion/react"; + +const container = { + show: { + transition: { + staggerChildren: ${num(v, "stagger", 0.06)}, + delayChildren: ${num(v, "delayChildren", 0)}, + staggerDirection: ${dir}, + }, + }, +}; + +const child = { + hidden: { opacity: 0, scale: 0.4, y: 12 }, + show: { opacity: 1, scale: 1, y: 0 }, +}; + +export function Demo() { + return ( + + {items.map((item) => ( + + ))} + + ); +}`; + }, +}; diff --git a/components/app/playground/items/travel-preview.tsx b/components/app/playground/items/travel-preview.tsx new file mode 100644 index 0000000..ab6c6d7 --- /dev/null +++ b/components/app/playground/items/travel-preview.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { motion, type Transition } from "motion/react"; + +const TRAVEL = 168; + +/** + * Shared subject for time/physics types: a box travels a fixed distance from a + * dashed "start" ghost to a "target" guide. Springs visibly overshoot the line + * before settling, so a first-timer can see what the numbers do. + */ +export function TravelPreview({ + transition, + replayKey, + strobe, + caption, +}: { + transition: Transition; + replayKey: number; + /** Box position (0..1 of travel) sampled at equal time slices. Bunched dots + * = slow, spread = fast — makes an easing curve legible on a plain slide. */ + strobe?: number[]; + /** Plain-English meaning of the current motion (e.g. what "easeOut" does). */ + caption?: { title: string; text: string }; +}) { + return ( +
+
+ {/* baseline + strobe trail of where the box sits over equal time slices */} +
+ {strobe?.map((f, i) => ( + + ))} + + {/* start ghost */} +
+ + start + + + {/* target guide */} +
+ + target + + + {/* moving box */} + +
+ + {caption ? ( +
+

+ {caption.title} +

+

+ {caption.text} +

+
+ ) : null} +
+ ); +} diff --git a/components/app/playground/items/tween.tsx b/components/app/playground/items/tween.tsx new file mode 100644 index 0000000..07e9374 --- /dev/null +++ b/components/app/playground/items/tween.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useReducedMotion } from "motion/react"; +import { arr, num, type PlaygroundItem, type Values } from "../core"; +import { TravelPreview } from "./travel-preview"; + +const FALLBACK = [0, 0, 0.58, 1]; + +/** cubic-bezier(x1,y1,x2,y2) as an easing fn: linear time (0..1) -> eased progress. */ +function bezierEasing([x1, y1, x2, y2]: number[]) { + const cx = 3 * x1; + const bx = 3 * (x2 - x1) - cx; + const ax = 1 - cx - bx; + const cy = 3 * y1; + const by = 3 * (y2 - y1) - cy; + const ay = 1 - cy - by; + const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t; + const sampleY = (t: number) => ((ay * t + by) * t + cy) * t; + const dX = (t: number) => (3 * ax * t + 2 * bx) * t + cx; + return (x: number) => { + // Newton-Raphson to invert x(t), then read y(t) + let t = x; + for (let i = 0; i < 8; i++) { + const err = sampleX(t) - x; + if (Math.abs(err) < 1e-5) break; + const d = dX(t); + if (Math.abs(d) < 1e-6) break; + t -= err / d; + } + return sampleY(t); + }; +} + +// nine equal time slices — the box's eased position at each +const SLICES = Array.from({ length: 9 }, (_, i) => (i + 1) / 10); + +// Named curves and what they feel like, matched to the current handles. +const NAMED: { curve: number[]; title: string; text: string }[] = [ + { curve: [0, 0, 1, 1], title: "linear", text: "Constant speed the whole way. Mechanical, no acceleration." }, + { curve: [0.42, 0, 1, 1], title: "easeIn", text: "Starts slow, speeds up into the end. Good for things leaving." }, + { curve: [0, 0, 0.58, 1], title: "easeOut", text: "Starts fast, slows into the end. Good for things arriving." }, + { curve: [0.42, 0, 0.58, 1], title: "easeInOut", text: "Slow start and finish, fast through the middle. Smooth and natural." }, +]; + +const matchCaption = (c: number[]) => { + const hit = NAMED.find((n) => n.curve.every((v, i) => Math.abs(v - c[i]) < 0.02)); + return ( + hit ?? { + title: "custom", + text: "Your own pacing. Steeper parts of the curve move faster.", + } + ); +}; + +function TweenPreview({ + values, + replayKey, +}: { + values: Values; + replayKey: number; +}) { + const reduce = useReducedMotion(); + const curve = arr(values, "curve", FALLBACK); + const ease = bezierEasing(curve); + return ( + + ); +} + +const fmt = (e: number[]) => `[${e.map((n) => +n.toFixed(3)).join(", ")}]`; + +export const tweenItem: PlaygroundItem = { + slug: "tween", + label: "Tween / Ease", + blurb: + "Time-based motion. Duration sets how long; the cubic-bezier curve sets the pacing. Drag the handles to shape it.", + controls: [ + { + kind: "slider", + key: "duration", + label: "Duration", + hint: "How long the animation takes, start to finish.", + min: 0.1, + max: 3, + step: 0.05, + unit: "s", + }, + { + kind: "curve", + key: "curve", + label: "Easing curve", + hint: "Pacing over time. Drag the handles — steep = fast, flat = slow.", + }, + ], + defaults: { duration: 0.6, curve: [0, 0, 0.58, 1] }, + presets: [ + { name: "linear", values: { curve: [0, 0, 1, 1] } }, + { name: "easeIn", values: { curve: [0.42, 0, 1, 1] } }, + { name: "easeOut", values: { curve: [0, 0, 0.58, 1] } }, + { name: "easeInOut", values: { curve: [0.42, 0, 0.58, 1] } }, + ], + Preview: TweenPreview, + explain: (v) => { + const dur = num(v, "duration", 0.6); + const curve = arr(v, "curve", FALLBACK); + const cap = matchCaption(curve); + return [ + { + code: `duration: ${dur}`, + text: `A fixed time, in seconds. The whole move always takes ${dur}s, no matter the distance. (Springs don't have this — tweens do.)`, + }, + { + code: `ease: [${curve.map((n) => +n.toFixed(2)).join(", ")}]`, + text: "The easing curve, written as a cubic-bezier. The four numbers are two control handles (x1, y1, x2, y2) that bend how speed changes over time. You rarely type these by hand — drag the curve handles instead.", + }, + { + code: cap.title, + text: `This curve reads as "${cap.title}": ${cap.text} The dots in the preview are the box's position at equal time slices, so spacing shows speed.`, + }, + ]; + }, + toCode: (v) => `import { motion } from "motion/react"; + +export function Demo() { + return ( + + ); +}`, +}; diff --git a/components/app/playground/playground.tsx b/components/app/playground/playground.tsx new file mode 100644 index 0000000..7fcd808 --- /dev/null +++ b/components/app/playground/playground.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { RotateCw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/motion/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/motion/select"; +import { cn } from "@/lib/utils"; +import { CodePanel } from "./code-panel"; +import { Controls } from "./controls"; +import type { ControlValue, ExplainPoint, Preset, Values } from "./core"; +import { PLAYGROUND_ITEMS, PLAYGROUND_SOON } from "./items"; + +/** Plain-English decode of the current code — the teaching layer. */ +function ExplainPanel({ points }: { points: ExplainPoint[] }) { + return ( +
+ + How it works + +
    + {points.map((p) => ( +
  • + + {p.code} + +

    + {p.text} +

    +
  • + ))} +
+
+ ); +} + +/** Quick-start preset chips. Plain starting points to play from — the type's + * own defaults load first. */ +function PresetSection({ + presets, + onApply, +}: { + presets: Preset[]; + onApply: (values: Values) => void; +}) { + if (presets.length === 0) return null; + return ( +
+ + Try + +
+ {presets.map((p) => ( + + ))} +
+
+ ); +} + +export function Playground() { + const [activeSlug, setActiveSlug] = useState(PLAYGROUND_ITEMS[0].slug); + const [replayKey, setReplayKey] = useState(0); + // per-type values so switching types preserves each one's tweaks + const [valuesByType, setValuesByType] = useState>(() => + Object.fromEntries( + PLAYGROUND_ITEMS.map((it) => [it.slug, { ...it.defaults }]), + ), + ); + + const active = useMemo( + () => + PLAYGROUND_ITEMS.find((it) => it.slug === activeSlug) ?? + PLAYGROUND_ITEMS[0], + [activeSlug], + ); + const values = valuesByType[active.slug]; + + const replay = () => setReplayKey((k) => k + 1); + + const select = (slug: string) => { + setActiveSlug(slug); + replay(); + }; + + const setValue = (key: string, value: ControlValue) => { + // strip float artifacts from step snapping (0.30000000000000004 -> 0.3) + const clean = + typeof value === "number" ? Math.round(value * 1e6) / 1e6 : value; + setValuesByType((prev) => { + const merged = { ...prev[active.slug], [key]: clean }; + // let the type reconcile dependent controls (e.g. property -> frames) + const nextValues = active.coerce ? active.coerce(key, merged) : merged; + return { ...prev, [active.slug]: nextValues }; + }); + replay(); + }; + + const applyPreset = (presetValues: Values) => { + setValuesByType((prev) => ({ + ...prev, + [active.slug]: { ...prev[active.slug], ...presetValues }, + })); + replay(); + }; + + const Preview = active.Preview; + + return ( +
+
+

+ Playground +

+

+ Learn motion by playing. Tweak a property, watch it run, read what the + code is doing line by line, then copy it. +

+
+ + {/* mobile type switcher */} +
+ +
+ +
+ {/* sidebar */} + + + {/* main */} +
+ {/* vocab: define the type for first-timers */} +
+

+ {active.label} +

+

+ {active.blurb} +

+
+ +
+ {/* left column: preview, then code stacked at the same width */} +
+
+
+ + Preview + + +
+
+ +
+
+ + +
+ + {/* right column: controls, then the plain-English decode */} +
+
+ +
+ +
+
+ + +
+
+
+
+
+ ); +} diff --git a/components/app/site-header.tsx b/components/app/site-header.tsx index dada137..7d5104c 100644 --- a/components/app/site-header.tsx +++ b/components/app/site-header.tsx @@ -1,18 +1,17 @@ "use client"; -import Link from "next/link"; -import Image from "next/image"; +import { Star, SwatchBook } from "lucide-react"; import { useMotionValueEvent, useScroll } from "motion/react"; -import { Star } from "lucide-react"; -import { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; import { usePathname } from "next/navigation"; -import { SwatchBook } from "lucide-react"; +import { useState } from "react"; import { GithubIcon } from "@/components/app/icons"; import { MobileNav } from "@/components/app/mobile-nav"; +import { usePreferences } from "@/components/app/preferences-provider"; import { PressLink } from "@/components/app/press-link"; import { SiteSearch } from "@/components/app/site-search"; import { Tooltip } from "@/components/motion/tooltip"; -import { usePreferences } from "@/components/app/preferences-provider"; import { cn } from "@/lib/utils"; function formatStarCount(count: number) { @@ -37,6 +36,7 @@ export function SiteHeader({ (pathname.startsWith("/components") && !pathname.startsWith("/components/blocks")); const isBlocks = pathname.startsWith("/components/blocks"); + const isPlayground = pathname.startsWith("/playground"); const formattedStarCount = typeof githubStarCount === "number" ? formatStarCount(githubStarCount) @@ -95,6 +95,17 @@ export function SiteHeader({ > Blocks + + Playground +
diff --git a/components/motion/select.tsx b/components/motion/select.tsx new file mode 100644 index 0000000..e23127d --- /dev/null +++ b/components/motion/select.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import { + motion, + type Transition, + useReducedMotion, + type Variants, +} from "motion/react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { EASE_OUT } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +// Bouncy unfold: the panel grows out of the trigger's edge (height 0 -> content) +// and a gap opens beneath it, so it reads as separating from the trigger rather +// than fading in. Mirrors the bouncy-accordion's spring-with-bounce feel. +const OPEN_TRANSITION: Transition = { type: "spring", duration: 0.5, bounce: 0.34 }; +const CLOSE_TRANSITION: Transition = { type: "spring", duration: 0.3, bounce: 0.12 }; +const CHEVRON_TRANSITION: Transition = { type: "spring", duration: 0.4, bounce: 0.3 }; + +const LIST_VARIANTS: Variants = { + hidden: {}, + show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } }, +}; +const ITEM_VARIANTS: Variants = { + hidden: { opacity: 0, y: -6, filter: "blur(3px)" }, + show: { opacity: 1, y: 0, filter: "blur(0px)" }, +}; + +interface SelectContextValue { + value: string | undefined; + open: boolean; + setOpen: (open: boolean) => void; + select: (value: string) => void; + register: (value: string, label: string) => void; + unregister: (value: string) => void; + labelFor: (value: string | undefined) => string | undefined; + reduce: boolean; + triggerId: string; + listId: string; + disabled: boolean; +} + +const SelectContext = createContext(null); + +function useSelectContext(component: string) { + const ctx = useContext(SelectContext); + if (!ctx) throw new Error(`${component} must be used within + + + + + Next.js + Remix + Astro + Vite + + +
+ ); +} diff --git a/components/previews/motion/text-animation.preview.tsx b/components/previews/motion/text-animation.preview.tsx index eba1e12..938c651 100644 --- a/components/previews/motion/text-animation.preview.tsx +++ b/components/previews/motion/text-animation.preview.tsx @@ -1,10 +1,10 @@ "use client"; import { AnimatePresence, motion } from "motion/react"; -import { EASE_OUT } from "@/lib/ease"; import { useEffect, useState } from "react"; import { TextReveal } from "@/components/motion/text-reveal"; import { TextShimmer } from "@/components/motion/text-shimmer"; +import { EASE_OUT } from "@/lib/ease"; export function TextAnimationPreview() { const [variant, setVariant] = useState<"reveal" | "shimmer">("reveal"); diff --git a/lib/registry.ts b/lib/registry.ts index 6ba383a..122a747 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -103,6 +103,13 @@ export const registry: CategoryEntry[] = [ description: "Toggle with a spring-driven thumb and press feedback.", file: "components/motion/switch.tsx", }, + { + slug: "select", + name: "Select", + description: "Composable select primitives (Select, SelectTrigger, SelectValue, SelectContent, SelectItem) whose panel bouncily unfolds out of the trigger and separates, with staggered items.", + file: "components/motion/select.tsx", + badge: "new", + }, { slug: "checkbox", name: "Checkbox",