{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"spring-playground","type":"registry:component","title":"Spring playground","description":"Tune a spring and watch its overshoot and settle, plotted live.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/spring-playground.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Mode = \"physics\" | \"perceptual\";\ntype Physics = { stiffness: number; damping: number; mass: number };\ntype Perceptual = { visualDuration: number; bounce: number };\n\nconst BALL = 40;\n// A release faster than this (px per second) counts as a throw toward that\n// side; anything slower settles on whichever end is closer.\nconst THROW_VELOCITY = 400;\n// How far the ball can be pulled past either end before it stops giving.\nconst MAX_STRETCH = 32;\n// Motion ends a spring once it is within 0.5px of the target and moving\n// slower than 2px/s (its defaults for anything travelling 5px or more).\n// Using the same rule makes the settle time here match the real animation.\nconst REST_DELTA = 0.5;\nconst REST_SPEED = 2;\nconst SIM_STEP = 1 / 1000;\nconst SIM_LIMIT = 10;\n// Waits for a slider to rest before replaying, so dragging one doesn't\n// restart the ball on every tick.\nconst REPLAY_DELAY = 250;\nconst PLOT_HEIGHT = 136;\nconst PAD = { top: 12, right: 12, bottom: 26, left: 12 };\n// From rest, a spring toward 1 never dips below 0, so the floor is fixed.\nconst PLOT_FLOOR = -0.1;\nconst AXIS_STEPS = [0.5, 1, 1.5, 2, 3, 4, 6, 10];\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\nconst PRESETS: { name: string; use: string; physics: Physics }[] = [\n  {\n    name: \"Snappy UI\",\n    use: \"Menus, toggles, anything clicked all day. Fast and never overshoots.\",\n    physics: { stiffness: 500, damping: 45, mass: 1 },\n  },\n  {\n    name: \"Gentle\",\n    use: \"Larger surfaces easing into place, like panels and sheets.\",\n    physics: { stiffness: 120, damping: 22, mass: 1 },\n  },\n  {\n    name: \"Bouncy\",\n    use: \"Playful moments and drag releases. Use sparingly: bounce pulls the eye.\",\n    physics: { stiffness: 400, damping: 20, mass: 1 },\n  },\n  {\n    name: \"Sluggish\",\n    use: \"What to avoid. Overdamped and slow, so the UI seems to lag behind you.\",\n    physics: { stiffness: 80, damping: 30, mass: 1 },\n  },\n];\n\n// The exact conversion Motion applies to visualDuration and bounce.\nfunction toPhysics({ visualDuration, bounce }: Perceptual): Physics {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  const stiffness = root * root;\n  const ratio = Math.min(Math.max(1 - bounce, 0.05), 1);\n  return { stiffness, damping: 2 * ratio * Math.sqrt(stiffness), mass: 1 };\n}\n\n// The inverse. Lossy past critical damping: bounce can't go below zero, so\n// an overdamped spring comes back as the critically damped one.\nfunction toPerceptual({ stiffness, damping, mass }: Physics): Perceptual {\n  const natural = Math.sqrt(stiffness / mass);\n  return {\n    visualDuration: clamp(roundTo((2 * Math.PI) / (1.2 * natural), 0.05), 0.1, 1.5),\n    bounce: clamp(roundTo(1 - dampingRatio({ stiffness, damping, mass }), 0.05), 0, 0.9),\n  };\n}\n\nfunction dampingRatio({ stiffness, damping, mass }: Physics) {\n  return damping / (2 * Math.sqrt(stiffness * mass));\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction roundTo(value: number, step: number) {\n  return Number((Math.round(value / step) * step).toFixed(4));\n}\n\nfunction format(value: number, decimals: number) {\n  return Number(value.toFixed(decimals)).toString();\n}\n\n// Steps the spring equation forward 1ms at a time (semi-implicit Euler),\n// independently of Motion, so the curve is a second opinion rather than a\n// recording of the animation.\nfunction simulate({ stiffness, damping, mass }: Physics, distance: number) {\n  const samples: number[] = [];\n  let x = 0;\n  let v = 0;\n  let peak = 0;\n  let settle: number | null = null;\n  for (let i = 1; i <= SIM_LIMIT / SIM_STEP; i++) {\n    const a = (-stiffness * (x - 1) - damping * v) / mass;\n    v += a * SIM_STEP;\n    x += v * SIM_STEP;\n    peak = Math.max(peak, x);\n    samples.push(x);\n    if (\n      Math.abs(1 - x) * distance <= REST_DELTA &&\n      Math.abs(v) * distance <= REST_SPEED\n    ) {\n      settle = i * SIM_STEP;\n      break;\n    }\n  }\n  return { samples, peak, settle };\n}\n\nfunction tickStep(axis: number) {\n  if (axis <= 1) return 0.25;\n  if (axis <= 2) return 0.5;\n  if (axis <= 4) return 1;\n  return 2;\n}\n\nexport function SpringPlayground({ className }: { className?: string }) {\n  const reduceMotion = useReducedMotion();\n  const [mode, setMode] = useState<Mode>(\"physics\");\n  const [physics, setPhysics] = useState<Physics>(PRESETS[0].physics);\n  const [perceptual, setPerceptual] = useState<Perceptual>(() =>\n    toPerceptual(PRESETS[0].physics),\n  );\n  const spring = useMemo(\n    () => (mode === \"physics\" ? physics : toPhysics(perceptual)),\n    [mode, physics, perceptual],\n  );\n\n  const trackRef = useRef<HTMLDivElement>(null);\n  const [trackWidth, setTrackWidth] = useState(520);\n  const travel = Math.max(trackWidth - BALL, 1);\n\n  const sim = useMemo(() => simulate(spring, travel), [spring, travel]);\n\n  const plot = useMemo(() => {\n    const axis =\n      AXIS_STEPS.find((s) => s >= (sim.settle ?? SIM_LIMIT) * 1.15) ?? SIM_LIMIT;\n    // Headroom above the target grows with the overshoot, capped so a wild\n    // spring can't flatten the rest of the curve.\n    const top = Math.min(Math.max(1.25, sim.peak + 0.1), 2.2);\n    const width = trackWidth;\n    const innerH = PLOT_HEIGHT - PAD.top - PAD.bottom;\n    const sx = (width - PAD.left - PAD.right) / axis;\n    const y = (p: number) =>\n      PAD.top + ((top - p) / (top - PLOT_FLOOR)) * innerH;\n    let d = `M${PAD.left},${y(0)}`;\n    // A point every 4ms is finer than a pixel at any axis length here.\n    for (let i = 3; i < sim.samples.length; i += 4) {\n      const t = (i + 1) * SIM_STEP;\n      if (t > axis) break;\n      d += `L${(PAD.left + t * sx).toFixed(1)},${y(sim.samples[i]).toFixed(1)}`;\n    }\n    // Motion snaps to the target once at rest, so the line goes flat there.\n    if (sim.settle !== null) {\n      d += `L${(PAD.left + axis * sx).toFixed(1)},${y(1).toFixed(1)}`;\n    }\n    const step = tickStep(axis);\n    const ticks = Array.from(\n      { length: Math.floor(axis / step + 1e-6) + 1 },\n      (_, i) => i * step,\n    );\n    return { axis, width, sx, y, d, ticks, top };\n  }, [sim, trackWidth]);\n\n  const x = useMotionValue(0);\n  const time = useMotionValue(0);\n  const headOpacity = useMotionValue(0);\n  const side = useRef<0 | 1>(0);\n  const controls = useRef<AnimationPlaybackControls[]>([]);\n  const dragging = useRef<{ offset: number } | null>(null);\n  const [release, setRelease] = useState<number | null>(null);\n\n  // Everything the per-frame callbacks read lives in one ref, refreshed\n  // after each render, so following the ball never re-renders React.\n  const live = useRef({ spring, settle: sim.settle, plot, travel, from: 0, to: 1 });\n  useLayoutEffect(() => {\n    Object.assign(live.current, { spring, settle: sim.settle, plot, travel });\n  });\n\n  const headX = useTransform(time, (t) => PAD.left + t * live.current.plot.sx);\n  const headY = useTransform(x, (v) => {\n    const l = live.current;\n    return l.plot.y((v - l.from) / (l.to - l.from || 1));\n  });\n\n  const stopAll = () => {\n    controls.current.forEach((c) => c.stop());\n    controls.current = [];\n  };\n\n  const run = (target: 0 | 1, velocity?: number) => {\n    stopAll();\n    const l = live.current;\n    const from = x.get();\n    const to = target === 1 ? l.travel : 0;\n    const atRest = velocity === undefined && Math.abs(x.getVelocity()) < 1;\n    side.current = target;\n    l.from = from;\n    l.to = to;\n    // Always physics values, even in perceptual mode: Motion discards the\n    // release velocity for visualDuration springs, and carrying it is the\n    // whole point of a throw.\n    controls.current.push(\n      animate(x, to, {\n        type: \"spring\",\n        ...l.spring,\n        ...(velocity !== undefined && { velocity }),\n      }),\n    );\n    // The playhead only means something for a start from rest, which is\n    // what the curve plots.\n    if (atRest && Math.abs(to - from) > 1) {\n      const settle = l.settle ?? SIM_LIMIT;\n      headOpacity.set(1);\n      time.set(0);\n      controls.current.push(\n        animate(time, settle, { duration: settle, ease: \"linear\" }),\n      );\n    } else {\n      headOpacity.set(0);\n    }\n  };\n\n  const play = () => {\n    setRelease(null);\n    run(side.current === 0 ? 1 : 0);\n  };\n\n  const playRef = useRef(play);\n  useLayoutEffect(() => {\n    playRef.current = play;\n  });\n\n  // Replays whenever the spring changes, once the control comes to rest.\n  // Skipped on mount and under reduced motion, where only Play moves it.\n  const key = `${spring.stiffness}|${spring.damping}|${spring.mass}`;\n  const lastKey = useRef(key);\n  useEffect(() => {\n    if (lastKey.current === key || reduceMotion) return;\n    lastKey.current = key;\n    const id = setTimeout(() => playRef.current(), REPLAY_DELAY);\n    return () => clearTimeout(id);\n  }, [key, reduceMotion]);\n\n  useEffect(() => {\n    const el = trackRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) => {\n      setTrackWidth(Math.round(entry.contentRect.width));\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // Keeps the ball parked on its end when the stage resizes.\n  useEffect(() => {\n    if (dragging.current) return;\n    controls.current.forEach((c) => c.stop());\n    controls.current = [];\n    x.jump(side.current === 1 ? travel : 0);\n    headOpacity.set(0);\n  }, [travel, x, headOpacity]);\n\n  useEffect(() => () => controls.current.forEach((c) => c.stop()), []);\n\n  const resist = (value: number) => {\n    const past = value < 0 ? value : value > travel ? value - travel : 0;\n    return (\n      clamp(value, 0, travel) +\n      MAX_STRETCH * Math.tanh(past / (MAX_STRETCH * 3))\n    );\n  };\n\n  const switchMode = (next: Mode) => {\n    if (next === mode) return;\n    if (next === \"perceptual\") setPerceptual(toPerceptual(physics));\n    else {\n      const p = toPhysics(perceptual);\n      setPhysics({\n        stiffness: Math.round(p.stiffness),\n        damping: roundTo(p.damping, 0.5),\n        mass: 1,\n      });\n    }\n    setMode(next);\n  };\n\n  const activePreset = PRESETS.findIndex((p) => {\n    if (mode === \"physics\") {\n      return (\n        p.physics.stiffness === physics.stiffness &&\n        p.physics.damping === physics.damping &&\n        p.physics.mass === physics.mass\n      );\n    }\n    const q = toPerceptual(p.physics);\n    return (\n      q.visualDuration === perceptual.visualDuration &&\n      q.bounce === perceptual.bounce\n    );\n  });\n\n  const ratio = dampingRatio(spring);\n  const behaviour =\n    Math.abs(ratio - 1) < 0.02\n      ? \"Critically damped\"\n      : ratio < 1\n        ? \"Underdamped\"\n        : \"Overdamped\";\n  const overshoot = Math.max(0, sim.peak - 1) * 100;\n  const settleText =\n    sim.settle === null ? \"Over 10s\" : `${format(sim.settle, 2)}s`;\n  // Hovering or focusing a preset previews its description in place.\n  const [previewPreset, setPreviewPreset] = useState<number | null>(null);\n  const described = previewPreset ?? activePreset;\n  const presetUseId = useId();\n\n  const code =\n    mode === \"physics\"\n      ? `{ type: \"spring\", stiffness: ${physics.stiffness}, damping: ${physics.damping}${physics.mass !== 1 ? `, mass: ${physics.mass}` : \"\"} }`\n      : `{ type: \"spring\", visualDuration: ${format(perceptual.visualDuration, 2)}, bounce: ${format(perceptual.bounce, 2)} }`;\n\n  const equivalent = toPerceptual(physics);\n  const note =\n    mode === \"physics\"\n      ? ratio > 1.02\n        ? `Too damped to express as bounce. The nearest perceptual spring is visualDuration ${format(equivalent.visualDuration, 2)}, bounce 0.`\n        : `Roughly visualDuration ${format(equivalent.visualDuration, 2)}, bounce ${format(equivalent.bounce, 2)} in perceptual terms.`\n      : `Motion runs this as stiffness ${format(spring.stiffness, 0)}, damping ${format(spring.damping, 1)}. It ignores release velocity for duration-based springs, so throws here use those numbers.`;\n\n  const { axis, width, sx, y, d, ticks } = plot;\n  const bottom = PLOT_HEIGHT - PAD.bottom;\n\n  return (\n    <div\n      className={cn(\n        // 12px inner panels plus 8px of padding keep the corners concentric.\n        \"flex w-[min(600px,100%)] flex-col gap-2 rounded-[20px] bg-muted p-2 text-foreground shadow-raised\",\n        className,\n      )}\n    >\n      <section className=\"rounded-xl bg-background p-3.5\" aria-label=\"Preview\">\n        <div className=\"mb-2 flex items-center justify-between gap-3\">\n          {/* Two lines reserved on phones, where the release text wraps. */}\n          <p className=\"flex min-h-10 min-w-0 items-center text-[13px] leading-5 text-muted-foreground sm:min-h-5\">\n            {release === null\n              ? \"Drag the ball and let go, or press Play.\"\n              : `Let go at ${Math.abs(release).toLocaleString(\"en-US\")} px/s. The spring started from that speed.`}\n          </p>\n          <button\n            type=\"button\"\n            onClick={play}\n            className=\"flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-foreground pr-4 pl-3 text-sm font-medium text-background outline-hidden transition-[scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n          >\n            {/* Nudged right: a triangle's visual center sits left of its box. */}\n            <svg viewBox=\"0 0 16 16\" className=\"size-4 translate-x-px\" fill=\"currentColor\" aria-hidden>\n              <path d=\"M5 3.6v8.8a.6.6 0 0 0 .9.5l7-4.4a.6.6 0 0 0 0-1l-7-4.4a.6.6 0 0 0-.9.5Z\" />\n            </svg>\n            Play\n          </button>\n        </div>\n\n        <div className=\"relative rounded-full bg-muted p-1\">\n          <div aria-hidden className=\"pointer-events-none absolute inset-1 flex justify-between\">\n            <span className=\"size-10 rounded-full border border-dashed border-muted-foreground/50\" />\n            <span className=\"size-10 rounded-full border border-dashed border-muted-foreground/50\" />\n          </div>\n          <div ref={trackRef} className=\"relative h-10\">\n            <motion.div\n              aria-hidden\n              style={{ x }}\n              className=\"absolute top-0 left-0 size-10 cursor-grab touch-none rounded-full bg-foreground shadow-raised active:cursor-grabbing\"\n              onPointerDown={(e) => {\n                if (e.button !== 0 || dragging.current || !trackRef.current) return;\n                stopAll();\n                headOpacity.set(0);\n                e.currentTarget.setPointerCapture(e.pointerId);\n                const box = trackRef.current.getBoundingClientRect();\n                dragging.current = { offset: e.clientX - box.left - x.get() };\n              }}\n              onPointerMove={(e) => {\n                if (!dragging.current || !trackRef.current) return;\n                const box = trackRef.current.getBoundingClientRect();\n                x.set(resist(e.clientX - box.left - dragging.current.offset));\n              }}\n              onPointerUp={() => {\n                if (!dragging.current) return;\n                dragging.current = null;\n                const velocity = Math.round(x.getVelocity());\n                const target: 0 | 1 =\n                  velocity > THROW_VELOCITY\n                    ? 1\n                    : velocity < -THROW_VELOCITY\n                      ? 0\n                      : x.get() > travel / 2\n                        ? 1\n                        : 0;\n                setRelease(velocity);\n                run(target, velocity);\n              }}\n              onPointerCancel={() => {\n                if (!dragging.current) return;\n                dragging.current = null;\n                run(x.get() > travel / 2 ? 1 : 0, 0);\n              }}\n            />\n          </div>\n        </div>\n\n        <figure className=\"mt-3\">\n          <svg\n            width={width}\n            height={PLOT_HEIGHT}\n            viewBox={`0 0 ${width} ${PLOT_HEIGHT}`}\n            className=\"block max-w-full overflow-visible\"\n            role=\"img\"\n            aria-label={`Spring curve. ${overshoot < 0.5 ? \"No overshoot\" : `Overshoots by ${format(overshoot, 0)} percent`}, ${sim.settle === null ? \"still moving after 10 seconds\" : `settles in ${format(sim.settle, 2)} seconds`}.`}\n          >\n            {ticks.map((t) => (\n              <g key={t}>\n                <line x1={PAD.left + t * sx} x2={PAD.left + t * sx} y1={PAD.top} y2={bottom} className=\"stroke-border\" />\n                <text\n                  x={PAD.left + t * sx}\n                  y={PLOT_HEIGHT - 6}\n                  textAnchor={t === 0 ? \"start\" : t >= axis - 1e-6 ? \"end\" : \"middle\"}\n                  className=\"fill-muted-foreground text-[12px] tabular-nums\"\n                >\n                  {format(t, 2)}s\n                </text>\n              </g>\n            ))}\n            <line x1={PAD.left} x2={width - PAD.right} y1={y(0)} y2={y(0)} className=\"stroke-border\" />\n            <line x1={PAD.left} x2={width - PAD.right} y1={y(1)} y2={y(1)} className=\"stroke-muted-foreground\" strokeDasharray=\"3 4\" />\n            <text x={width - PAD.right} y={y(1) - 6} textAnchor=\"end\" className=\"fill-muted-foreground text-[12px]\">\n              target\n            </text>\n            {/* Bottom right is always empty: every curve has reached the\n                target by then. The halo keeps the settle marker off the text. */}\n            <text\n              x={width - PAD.right}\n              y={y(0) - 6}\n              textAnchor=\"end\"\n              paintOrder=\"stroke\"\n              strokeWidth={4}\n              strokeLinejoin=\"round\"\n              className=\"fill-muted-foreground stroke-background text-[12px]\"\n            >\n              Position over time, from rest\n            </text>\n            {sim.settle !== null && (\n              <line\n                x1={PAD.left + sim.settle * sx}\n                x2={PAD.left + sim.settle * sx}\n                y1={PAD.top}\n                y2={bottom}\n                className=\"stroke-foreground/50\"\n                strokeDasharray=\"2 3\"\n              />\n            )}\n            <path d={d} fill=\"none\" className=\"stroke-foreground\" strokeWidth={2} strokeLinejoin=\"round\" />\n            <motion.circle\n              r={5}\n              cx={headX}\n              cy={headY}\n              style={{ opacity: headOpacity }}\n              className=\"fill-foreground stroke-background\"\n              strokeWidth={2}\n            />\n          </svg>\n          <dl className=\"mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4\">\n            <Stat label=\"Settles\" value={settleText} />\n            <Stat label=\"Overshoot\" value={overshoot < 0.5 ? \"None\" : `${format(overshoot, 0)}%`} />\n            <Stat label=\"Damping ratio\" value={format(ratio, 2)} />\n            <Stat label=\"Behaviour\" value={behaviour} />\n          </dl>\n        </figure>\n      </section>\n\n      <section className=\"rounded-xl bg-background p-3.5\" aria-label=\"Spring settings\">\n        <div className=\"flex flex-wrap items-center justify-between gap-2\">\n          <div role=\"group\" aria-label=\"Presets\" className=\"flex flex-wrap gap-1\">\n            {PRESETS.map((p, i) => (\n              <button\n                key={p.name}\n                type=\"button\"\n                aria-pressed={activePreset === i}\n                aria-describedby={presetUseId}\n                onClick={() =>\n                  mode === \"physics\"\n                    ? setPhysics(p.physics)\n                    : setPerceptual(toPerceptual(p.physics))\n                }\n                onPointerEnter={(e) => e.pointerType !== \"touch\" && setPreviewPreset(i)}\n                onPointerLeave={() => setPreviewPreset(null)}\n                onFocus={() => setPreviewPreset(i)}\n                onBlur={() => setPreviewPreset(null)}\n                className={cn(\n                  \"h-9 rounded-full px-3 text-[13px] font-medium outline-hidden transition-[scale,color,background-color] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,background-color]\",\n                  activePreset === i\n                    ? \"bg-muted text-foreground\"\n                    : \"text-muted-foreground hover:bg-muted/60 hover:text-foreground\",\n                )}\n              >\n                {p.name}\n              </button>\n            ))}\n          </div>\n          <ModeToggle mode={mode} onChange={switchMode} />\n        </div>\n        {/* Two lines reserved on phones so a longer description can't\n            push the sliders down. */}\n        <p\n          id={presetUseId}\n          className=\"mt-2 min-h-10 px-1 text-[13px] leading-5 text-pretty text-muted-foreground sm:min-h-5\"\n        >\n          {described === -1\n            ? \"Custom values. Pick a preset to start from a known spring.\"\n            : PRESETS[described].use}\n        </p>\n        {/* Phones fit two sliders per row; the fixed height covers the\n            three-slider mode so switching modes doesn't change the height. */}\n        <div className=\"mt-3 grid min-h-[180px] grid-cols-2 gap-x-5 gap-y-3 sm:min-h-0 sm:grid-cols-3\">\n          {mode === \"physics\" ? (\n            <>\n              <Range id=\"stiffness\" label=\"Stiffness\" hint=\"How hard it pulls to the target\" min={10} max={1000} step={1} value={physics.stiffness} decimals={0} onChange={(v) => setPhysics((p) => ({ ...p, stiffness: v }))} />\n              <Range id=\"damping\" label=\"Damping\" hint=\"How fast the motion dies out\" min={1} max={100} step={0.5} value={physics.damping} decimals={1} onChange={(v) => setPhysics((p) => ({ ...p, damping: v }))} />\n              <Range id=\"mass\" label=\"Mass\" hint=\"Heavier is slower and swings longer\" min={0.1} max={5} step={0.1} value={physics.mass} decimals={1} onChange={(v) => setPhysics((p) => ({ ...p, mass: v }))} />\n            </>\n          ) : (\n            <>\n              <Range id=\"visual-duration\" label=\"Visual duration\" unit=\"s\" hint=\"When it looks arrived; any bounce comes after\" min={0.1} max={1.5} step={0.05} value={perceptual.visualDuration} decimals={2} onChange={(v) => setPerceptual((p) => ({ ...p, visualDuration: v }))} />\n              <Range id=\"bounce\" label=\"Bounce\" hint=\"0 never overshoots, higher swings more\" min={0} max={0.9} step={0.05} value={perceptual.bounce} decimals={2} onChange={(v) => setPerceptual((p) => ({ ...p, bounce: v }))} />\n            </>\n          )}\n        </div>\n        {/* Reserved for the longer perceptual note, so switching modes\n            keeps the height (four lines on phones). */}\n        <p className=\"mt-3 min-h-20 text-[13px] leading-5 text-pretty text-muted-foreground sm:min-h-10\">{note}</p>\n      </section>\n\n      <div className=\"flex h-11 items-center gap-2 rounded-xl bg-background pr-1.5 pl-4\">\n        <code className=\"min-w-0 flex-1 truncate font-mono text-[13px]\">{code}</code>\n        <CopyButton value={code} />\n      </div>\n    </div>\n  );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }) {\n  return (\n    <div className=\"flex min-w-0 flex-col gap-0.5\">\n      <dt className=\"text-xs text-muted-foreground\">{label}</dt>\n      <dd className=\"truncate text-sm tabular-nums\">{value}</dd>\n    </div>\n  );\n}\n\nfunction ModeToggle({\n  mode,\n  onChange,\n}: {\n  mode: Mode;\n  onChange: (mode: Mode) => void;\n}) {\n  const options: { id: Mode; label: string }[] = [\n    { id: \"physics\", label: \"Physics\" },\n    { id: \"perceptual\", label: \"Perceptual\" },\n  ];\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label=\"Spring parameters\"\n      className=\"relative grid h-9 grid-cols-2 rounded-full bg-muted p-1\"\n      onKeyDown={(e) => {\n        if (![\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\"].includes(e.key)) return;\n        e.preventDefault();\n        const next = mode === \"physics\" ? \"perceptual\" : \"physics\";\n        onChange(next);\n        e.currentTarget\n          .querySelector<HTMLElement>(`[data-mode=\"${next}\"]`)\n          ?.focus();\n      }}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-background shadow-raised transition-[translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n          mode === \"perceptual\" && \"translate-x-full\",\n        )}\n      />\n      {options.map((o) => (\n        <button\n          key={o.id}\n          type=\"button\"\n          role=\"radio\"\n          data-mode={o.id}\n          aria-checked={mode === o.id}\n          tabIndex={mode === o.id ? 0 : -1}\n          onClick={() => onChange(o.id)}\n          className={cn(\n            \"relative rounded-full px-3.5 text-[13px] font-medium outline-hidden transition-[color] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n            mode === o.id ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n          )}\n        >\n          {o.label}\n        </button>\n      ))}\n    </div>\n  );\n}\n\nfunction Range({\n  id,\n  label,\n  hint,\n  unit = \"\",\n  min,\n  max,\n  step,\n  value,\n  decimals,\n  onChange,\n}: {\n  id: string;\n  label: string;\n  hint: string;\n  unit?: string;\n  min: number;\n  max: number;\n  step: number;\n  value: number;\n  decimals: number;\n  onChange: (value: number) => void;\n}) {\n  const uid = `spring-${id}`;\n  const fill = ((value - min) / (max - min)) * 100;\n  return (\n    <div className=\"flex min-w-0 flex-col\">\n      <div className=\"flex items-baseline justify-between gap-2\">\n        <label htmlFor={uid} className=\"text-sm font-medium\">\n          {label}\n        </label>\n        <output htmlFor={uid} className=\"text-sm tabular-nums\">\n          {value.toFixed(decimals)}\n          {unit}\n        </output>\n      </div>\n      <input\n        id={uid}\n        type=\"range\"\n        min={min}\n        max={max}\n        step={step}\n        value={value}\n        aria-describedby={`${uid}-hint`}\n        onChange={(e) => onChange(Number(e.target.value))}\n        style={{ \"--fill\": `${fill}%` } as React.CSSProperties}\n        className={cn(\n          \"h-8 w-full cursor-pointer appearance-none rounded-full bg-transparent outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n          // The filled part is a hard color stop at the value, not a blend.\n          \"[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--foreground)_var(--fill),var(--border)_var(--fill))]\",\n          \"[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[linear-gradient(to_right,var(--foreground)_var(--fill),var(--border)_var(--fill))]\",\n          // -6px centers the 16px thumb on the 4px track.\n          \"[&::-webkit-slider-thumb]:-mt-1.5 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-raised\",\n          \"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-raised\",\n        )}\n      />\n      <span id={`${uid}-hint`} className=\"text-xs leading-4 text-muted-foreground\">\n        {hint}\n      </span>\n    </div>\n  );\n}\n\nfunction CopyButton({ value }: { value: string }) {\n  const reduceMotion = useReducedMotion();\n  const [copied, setCopied] = useState(false);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const copy = async () => {\n    setCopied(true);\n    clearTimeout(timer.current);\n    // Long enough to notice the check, short enough to copy again soon.\n    timer.current = setTimeout(() => setCopied(false), 1600);\n    try {\n      await navigator.clipboard.writeText(value);\n    } catch {\n      clearTimeout(timer.current);\n      setCopied(false);\n    }\n  };\n\n  const hidden = reduceMotion\n    ? { opacity: 0 }\n    : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" };\n  const shown = { scale: 1, opacity: 1, filter: \"blur(0px)\" };\n\n  return (\n    <button\n      type=\"button\"\n      aria-label=\"Copy config\"\n      onClick={copy}\n      className=\"relative flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out select-none after:absolute after:-inset-1 hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n    >\n      <span className=\"grid\" aria-hidden>\n        <motion.svg viewBox=\"0 0 16 16\" className=\"col-start-1 row-start-1 size-4\" {...STROKE} initial={false} animate={copied ? hidden : shown} transition={ICON_SWAP}>\n          <rect x=\"5.25\" y=\"5.25\" width=\"8\" height=\"8\" rx=\"1.75\" />\n          <path d=\"M10.75 5.25V4.5a1.75 1.75 0 0 0-1.75-1.75H4.5A1.75 1.75 0 0 0 2.75 4.5V9a1.75 1.75 0 0 0 1.75 1.75h.75\" />\n        </motion.svg>\n        <motion.svg viewBox=\"0 0 16 16\" className=\"col-start-1 row-start-1 size-4 text-foreground\" {...STROKE} initial={false} animate={copied ? shown : hidden} transition={ICON_SWAP}>\n          <path d=\"m3.5 8.5 3 3 6-7\" />\n        </motion.svg>\n      </span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {copied ? \"Copied\" : \"\"}\n      </span>\n    </button>\n  );\n}\n\nconst STROKE = {\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.5,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n} as const;\n\nexport default function SpringPlaygroundDemo() {\n  return <SpringPlayground />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"marker":"#d93d31","shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"marker":"#ff6b5f","shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"color-marker":"var(--marker)","shadow-raised":"var(--shadow-raised)"}},"categories":["playground"],"docs":"From ui lab: https://lab.xevrion.dev/lab/spring-playground"}