{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"easing-editor","type":"registry:component","title":"Easing editor","description":"Drag a cubic-bezier into shape and see it move.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/easing-editor.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Curve = [number, number, number, number];\n\n// The graph shows a little time either side of 0..1 so the end handles\n// never sit on the edge, and progress from -0.5 to 1.5 so curves that\n// dip below the start (anticipation) or pass the end (overshoot) fit.\nconst T_MIN = -0.1;\nconst T_MAX = 1.1;\nconst P_MIN = -0.5;\nconst P_MAX = 1.5;\nconst SIZE = 280;\n// The pause at each end of the loop, so every run starts from stillness\n// and you can compare how it arrives.\nconst HOLD = 700;\nconst DURATIONS = [300, 600, 1000];\nconst STEP = 0.01;\nconst BIG_STEP = 0.1;\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\nconst PRESETS: { name: string; curve: Curve; use: string }[] = [\n  {\n    name: \"ease-out\",\n    curve: [0.23, 1, 0.32, 1],\n    use: \"Things entering or answering a click: dropdowns, toasts, modals. The fast start feels instant.\",\n  },\n  {\n    name: \"ease-in-out\",\n    curve: [0.77, 0, 0.175, 1],\n    use: \"Something already on screen moving to a new place. Speeds up, then settles.\",\n  },\n  {\n    name: \"iOS drawer\",\n    curve: [0.32, 0.72, 0, 1],\n    use: \"Sheets and drawers sliding in. An iOS-like curve from the Ionic Framework.\",\n  },\n  {\n    name: \"ease\",\n    curve: [0.25, 0.1, 0.25, 1],\n    use: \"The CSS default. Fine for hover and color changes, too soft for movement.\",\n  },\n  {\n    name: \"linear\",\n    curve: [0, 0, 1, 1],\n    use: \"Constant motion only: progress bars, marquees, spinners. Movement looks mechanical.\",\n  },\n  {\n    name: \"overshoot\",\n    curve: [0.34, 1.56, 0.64, 1],\n    use: \"Passes the target, then eases back. Playful, but a spring usually does it better.\",\n  },\n];\n\nconst X = (t: number) => ((t - T_MIN) / (T_MAX - T_MIN)) * SIZE;\nconst Y = (p: number) => ((P_MAX - p) / (P_MAX - P_MIN)) * SIZE;\nconst pct = (v: number) => `${(v / SIZE) * 100}%`;\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\n// Up to 3 decimals, trailing zeros dropped: 0.175 stays, 1.00 becomes 1.\nfunction num(v: number) {\n  return Number(v.toFixed(3)).toString();\n}\n\nfunction toCss(c: Curve) {\n  return `cubic-bezier(${c.map(num).join(\", \")})`;\n}\n\n// Builds one loop: out, hold, back, hold. Offsets come from real times so\n// the hold stays the same length whatever the duration.\nfunction timeline(duration: number) {\n  const total = duration * 2 + HOLD * 2;\n  return {\n    total,\n    arrive: duration / total,\n    leave: (duration + HOLD) / total,\n    back: (duration * 2 + HOLD) / total,\n  };\n}\n\nfunction travelFrames(curve: string, duration: number, from: string, to: string): Keyframe[] {\n  const t = timeline(duration);\n  return [\n    { transform: from, offset: 0, easing: curve },\n    { transform: to, offset: t.arrive },\n    { transform: to, offset: t.leave, easing: curve },\n    { transform: from, offset: t.back },\n    { transform: from, offset: 1 },\n  ];\n}\n\n// The graph's tracer only draws the outbound run, since the return trip\n// isn't the plotted curve. It rewinds while faded out.\nfunction tracerFrames(\n  curve: string,\n  duration: number,\n  axis: \"X\" | \"Y\",\n): Keyframe[] {\n  const t = timeline(duration);\n  const end = axis === \"X\" ? \"translateX(100%)\" : \"translateY(-100%)\";\n  const start = axis === \"X\" ? \"translateX(0%)\" : \"translateY(0%)\";\n  const rewind = (t.back + 1) / 2;\n  return [\n    { transform: start, offset: 0, easing: axis === \"X\" ? \"linear\" : curve },\n    { transform: end, offset: t.arrive },\n    { transform: end, offset: rewind },\n    { transform: start, offset: rewind },\n    { transform: start, offset: 1 },\n  ];\n}\n\nfunction fadeFrames(duration: number): Keyframe[] {\n  const t = timeline(duration);\n  const rewind = (t.back + 1) / 2;\n  return [\n    { opacity: 1, offset: 0 },\n    { opacity: 1, offset: t.leave },\n    { opacity: 0, offset: Math.min(t.leave + 0.05, rewind) },\n    { opacity: 0, offset: rewind },\n    { opacity: 1, offset: 1 },\n  ];\n}\n\nexport function EasingEditor({\n  initial = PRESETS[0].curve,\n  className,\n}: {\n  initial?: Curve;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [curve, setCurve] = useState<Curve>(initial);\n  const [duration, setDuration] = useState(DURATIONS[1]);\n  const css = toCss(curve);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const graphRef = useRef<HTMLDivElement>(null);\n  const dotRef = useRef<HTMLDivElement>(null);\n  const squareRef = useRef<HTMLDivElement>(null);\n  const tracerXRef = useRef<HTMLDivElement>(null);\n  const tracerYRef = useRef<HTMLDivElement>(null);\n  const tracerDotRef = useRef<HTMLDivElement>(null);\n  const anims = useRef<{ eased: Animation[]; all: Animation[] }>({ eased: [], all: [] });\n  const visible = useRef(true);\n  const cssRef = useRef(css);\n  const dragging = useRef<0 | 1 | null>(null);\n  const hintId = useId();\n\n  // WAAPI rather than Motion: it runs off the main thread and takes the\n  // cubic-bezier string exactly as CSS would, overshoot included.\n  useEffect(() => {\n    const els = [dotRef, squareRef, tracerXRef, tracerYRef, tracerDotRef].map((r) => r.current);\n    if (els.some((el) => !el)) return;\n    const [dot, square, tx, ty, tdot] = els as HTMLElement[];\n    const { total } = timeline(duration);\n    const timing = { duration: total, iterations: Infinity };\n    const c = cssRef.current;\n    const eased = [\n      dot.animate(travelFrames(c, duration, \"translateX(0%)\", \"translateX(100%)\"), timing),\n      square.animate(travelFrames(c, duration, \"scale(0.5)\", \"scale(1)\"), timing),\n      ty.animate(tracerFrames(c, duration, \"Y\"), timing),\n    ];\n    const all = [\n      ...eased,\n      tx.animate(tracerFrames(c, duration, \"X\"), timing),\n      tdot.animate(fadeFrames(duration), timing),\n    ];\n    anims.current = { eased, all };\n    return () => all.forEach((a) => a.cancel());\n  }, [duration]);\n\n  // A new curve swaps the keyframes in place, so the loop keeps its time\n  // instead of restarting on every drag frame.\n  useEffect(() => {\n    cssRef.current = css;\n    const [dot, square, ty] = anims.current.eased;\n    if (!dot) return;\n    (dot.effect as KeyframeEffect).setKeyframes(\n      travelFrames(css, duration, \"translateX(0%)\", \"translateX(100%)\"),\n    );\n    (square.effect as KeyframeEffect).setKeyframes(\n      travelFrames(css, duration, \"scale(0.5)\", \"scale(1)\"),\n    );\n    (ty.effect as KeyframeEffect).setKeyframes(tracerFrames(css, duration, \"Y\"));\n  }, [css, duration]);\n\n  // Reduced motion starts paused; Play is still one press away.\n  const [override, setOverride] = useState<boolean | null>(null);\n  const isPaused = override ?? !!reduceMotion;\n\n  useEffect(() => {\n    const sync = () => {\n      anims.current.all.forEach((a) =>\n        isPaused || !visible.current ? a.pause() : a.play(),\n      );\n    };\n    sync();\n    // Nothing loops while scrolled out of view.\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => {\n      visible.current = entry.isIntersecting;\n      sync();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [isPaused, duration]);\n\n  const moveTo = (index: 0 | 1, clientX: number, clientY: number) => {\n    const box = graphRef.current?.getBoundingClientRect();\n    if (!box) return;\n    const t = T_MIN + ((clientX - box.left) / box.width) * (T_MAX - T_MIN);\n    const p = P_MAX - ((clientY - box.top) / box.height) * (P_MAX - P_MIN);\n    set(index, t, p);\n  };\n\n  const set = (index: 0 | 1, t: number, p: number) => {\n    // CSS rejects x outside 0..1, so time is clamped; progress may overshoot.\n    const nx = Math.round(clamp(t, 0, 1) / STEP) * STEP;\n    const ny = Math.round(clamp(p, P_MIN, P_MAX) / STEP) * STEP;\n    setCurve((c) => {\n      const next = [...c] as Curve;\n      next[index * 2] = Number(nx.toFixed(3));\n      next[index * 2 + 1] = Number(ny.toFixed(3));\n      return next;\n    });\n  };\n\n  const active = PRESETS.find((p) => p.curve.every((v, i) => v === curve[i]));\n  const points: [number, number][] = [\n    [curve[0], curve[1]],\n    [curve[2], curve[3]],\n  ];\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        // 12px panels plus 8px of padding keep the corners concentric.\n        \"flex w-[min(560px,100%)] flex-col gap-2 rounded-[20px] bg-muted p-2 text-foreground shadow-raised\",\n        className,\n      )}\n    >\n      <section\n        aria-label=\"Curve editor\"\n        className=\"flex flex-wrap items-start gap-5 rounded-xl bg-background p-3.5\"\n      >\n        <div className=\"flex w-[min(280px,100%)] flex-col gap-2\">\n          <div ref={graphRef} className=\"relative aspect-square w-full select-none\">\n            <svg viewBox={`0 0 ${SIZE} ${SIZE}`} className=\"absolute inset-0 size-full overflow-visible\" aria-hidden>\n              <rect x={X(0)} y={Y(1)} width={X(1) - X(0)} height={Y(0) - Y(1)} className=\"fill-muted\" rx={4} />\n              {[0.25, 0.5, 0.75].map((v) => (\n                <g key={v} className=\"stroke-border\">\n                  <line x1={X(v)} x2={X(v)} y1={Y(1)} y2={Y(0)} />\n                  <line x1={X(0)} x2={X(1)} y1={Y(v)} y2={Y(v)} />\n                </g>\n              ))}\n              <text x={X(0)} y={Y(1) - 6} className=\"fill-muted-foreground text-[12px]\">\n                progress\n              </text>\n              <text x={X(1)} y={Y(0) + 16} textAnchor=\"end\" className=\"fill-muted-foreground text-[12px]\">\n                time\n              </text>\n              <text x={X(1)} y={Y(1.5) + 14} textAnchor=\"end\" className=\"fill-muted-foreground text-[12px]\">\n                overshoot\n              </text>\n              <text x={X(1)} y={Y(-0.5) - 6} textAnchor=\"end\" className=\"fill-muted-foreground text-[12px]\">\n                anticipation\n              </text>\n              {/* Linear, for reference: anything above it is ahead of schedule. */}\n              <line x1={X(0)} y1={Y(0)} x2={X(1)} y2={Y(1)} className=\"stroke-muted-foreground/60\" strokeDasharray=\"3 4\" />\n              <line x1={X(0)} y1={Y(0)} x2={X(curve[0])} y2={Y(curve[1])} className=\"stroke-muted-foreground\" strokeWidth={1.5} />\n              <line x1={X(1)} y1={Y(1)} x2={X(curve[2])} y2={Y(curve[3])} className=\"stroke-muted-foreground\" strokeWidth={1.5} />\n              <path\n                d={`M${X(0)},${Y(0)} C${X(curve[0])},${Y(curve[1])} ${X(curve[2])},${Y(curve[3])} ${X(1)},${Y(1)}`}\n                fill=\"none\"\n                className=\"stroke-foreground\"\n                strokeWidth={2.5}\n                strokeLinecap=\"round\"\n              />\n              <circle cx={X(0)} cy={Y(0)} r={3} className=\"fill-foreground\" />\n              <circle cx={X(1)} cy={Y(1)} r={3} className=\"fill-foreground\" />\n            </svg>\n\n            {/* A dot that rides the curve: it moves across at a steady rate\n                (time) while rising with the easing (progress). */}\n            <div\n              aria-hidden\n              className=\"pointer-events-none absolute\"\n              style={{ left: pct(X(0)), top: pct(Y(1)), width: pct(X(1) - X(0)), height: pct(Y(0) - Y(1)) }}\n            >\n              <div ref={tracerXRef} className=\"absolute inset-0\">\n                <div ref={tracerYRef} className=\"absolute top-0 left-0 h-full w-0\">\n                  <div\n                    ref={tracerDotRef}\n                    className=\"absolute bottom-0 left-0 size-2.5 -translate-x-1/2 translate-y-1/2 rounded-full bg-foreground ring-2 ring-background\"\n                  />\n                </div>\n              </div>\n            </div>\n\n            {points.map(([t, p], i) => (\n              <button\n                key={i}\n                type=\"button\"\n                aria-label={`Control point ${i + 1}: time ${num(t)}, progress ${num(p)}`}\n                aria-describedby={hintId}\n                className=\"absolute size-4 -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none rounded-full border-2 border-foreground bg-background outline-hidden transition-[scale] duration-150 ease-out after:absolute after:-inset-3 after:rounded-full focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[1.1] active:cursor-grabbing motion-reduce:transition-none\"\n                style={{ left: pct(X(t)), top: pct(Y(p)) }}\n                onPointerDown={(e) => {\n                  if (e.button !== 0 || dragging.current !== null) return;\n                  e.currentTarget.setPointerCapture(e.pointerId);\n                  dragging.current = i as 0 | 1;\n                }}\n                onPointerMove={(e) => {\n                  if (dragging.current === i) moveTo(i as 0 | 1, e.clientX, e.clientY);\n                }}\n                onPointerUp={() => {\n                  dragging.current = null;\n                }}\n                onPointerCancel={() => {\n                  dragging.current = null;\n                }}\n                onKeyDown={(e) => {\n                  const step = e.shiftKey ? BIG_STEP : STEP;\n                  const delta = {\n                    ArrowLeft: [-step, 0],\n                    ArrowRight: [step, 0],\n                    ArrowUp: [0, step],\n                    ArrowDown: [0, -step],\n                  }[e.key];\n                  if (!delta) return;\n                  e.preventDefault();\n                  set(i as 0 | 1, t + delta[0], p + delta[1]);\n                }}\n              />\n            ))}\n          </div>\n          <p id={hintId} className=\"text-xs leading-4 text-muted-foreground\">\n            Drag a handle. Arrow keys nudge, Shift more.\n          </p>\n        </div>\n\n        <div className=\"flex min-w-[200px] flex-1 flex-col gap-4\">\n          <div className=\"flex items-center justify-between gap-2\">\n            <span className=\"text-sm font-medium\">Preview</span>\n            <button\n              type=\"button\"\n              aria-label={isPaused ? \"Play preview\" : \"Pause preview\"}\n              onClick={() => setOverride(!isPaused)}\n              className=\"relative flex size-8 items-center justify-center rounded-full text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out 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                <SwapIcon visible={isPaused} reduceMotion={reduceMotion}>\n                  {/* Shifted right: a triangle's visual center sits left of its box. */}\n                  <path d=\"M5.5 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\" fill=\"currentColor\" stroke=\"none\" />\n                </SwapIcon>\n                <SwapIcon visible={!isPaused} reduceMotion={reduceMotion}>\n                  <path d=\"M5.5 3.5v9M10.5 3.5v9\" strokeWidth={2} />\n                </SwapIcon>\n              </span>\n            </button>\n          </div>\n\n          <div className=\"rounded-full bg-muted p-1\">\n            <div className=\"relative h-8\">\n              {/* Travel is the track minus the dot, so translateX(100%) of\n                  this box lands the dot exactly on the far end. */}\n              <div ref={dotRef} className=\"absolute inset-y-0 left-0 w-[calc(100%-32px)] will-change-transform\">\n                <div className=\"size-8 rounded-full bg-foreground\" />\n              </div>\n            </div>\n          </div>\n\n          <div className=\"flex h-28 items-center justify-center rounded-xl bg-muted\">\n            <div className=\"relative grid size-16 place-items-center\">\n              <div aria-hidden className=\"absolute inset-0 rounded-[14px] border border-dashed border-muted-foreground/50\" />\n              <div ref={squareRef} className=\"size-16 rounded-[14px] bg-foreground will-change-transform\" />\n            </div>\n          </div>\n\n          <div\n            role=\"radiogroup\"\n            aria-label=\"Preview duration\"\n            className=\"grid grid-cols-3 gap-1 rounded-full bg-muted p-1\"\n            onKeyDown={(e) => {\n              const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key];\n              if (!dir) return;\n              e.preventDefault();\n              const i = DURATIONS.indexOf(duration);\n              const next = DURATIONS[(i + dir + DURATIONS.length) % DURATIONS.length];\n              setDuration(next);\n              e.currentTarget.querySelector<HTMLElement>(`[data-duration=\"${next}\"]`)?.focus();\n            }}\n          >\n            {DURATIONS.map((d) => (\n              <button\n                key={d}\n                type=\"button\"\n                role=\"radio\"\n                data-duration={d}\n                aria-checked={duration === d}\n                tabIndex={duration === d ? 0 : -1}\n                onClick={() => setDuration(d)}\n                className={cn(\n                  \"h-8 rounded-full text-[13px] font-medium tabular-nums outline-hidden transition-[color,background-color,scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n                  duration === d ? \"bg-background text-foreground shadow-raised\" : \"text-muted-foreground hover:text-foreground\",\n                )}\n              >\n                {d}ms\n              </button>\n            ))}\n          </div>\n        </div>\n      </section>\n\n      <section aria-label=\"Presets\" className=\"rounded-xl bg-background p-3.5\">\n        <div className=\"grid grid-cols-2 gap-1.5 sm:grid-cols-3\">\n          {PRESETS.map((p) => (\n            <button\n              key={p.name}\n              type=\"button\"\n              aria-pressed={active === p}\n              onClick={() => setCurve(p.curve)}\n              className={cn(\n                \"h-9 rounded-lg px-3 font-mono text-[13px] outline-hidden transition-[color,background-color,scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n                active === p ? \"bg-foreground text-background\" : \"bg-muted text-muted-foreground hover:text-foreground\",\n              )}\n            >\n              {p.name}\n            </button>\n          ))}\n        </div>\n        {/* Reserves two lines so switching presets never shifts the page. */}\n        <p className=\"mt-3 min-h-10 text-sm leading-5 text-pretty text-muted-foreground\" aria-live=\"polite\">\n          {active ? active.use : \"Custom curve. Steeper at the start means it answers faster; a handle above the square overshoots.\"}\n        </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]\">{css}</code>\n        <CopyButton value={css} />\n      </div>\n    </div>\n  );\n}\n\nfunction SwapIcon({\n  visible,\n  reduceMotion,\n  children,\n}: {\n  visible: boolean;\n  reduceMotion: boolean | null;\n  children: React.ReactNode;\n}) {\n  const hidden = reduceMotion\n    ? { opacity: 0 }\n    : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" };\n  return (\n    <motion.svg\n      viewBox=\"0 0 16 16\"\n      className=\"col-start-1 row-start-1 size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      initial={false}\n      animate={visible ? { scale: 1, opacity: 1, filter: \"blur(0px)\" } : hidden}\n      transition={ICON_SWAP}\n    >\n      {children}\n    </motion.svg>\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  return (\n    <button\n      type=\"button\"\n      aria-label=\"Copy easing\"\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        <SwapIcon visible={!copied} reduceMotion={reduceMotion}>\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        </SwapIcon>\n        <SwapIcon visible={copied} reduceMotion={reduceMotion}>\n          <path d=\"m3.5 8.5 3 3 6-7\" />\n        </SwapIcon>\n      </span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {copied ? \"Copied\" : \"\"}\n      </span>\n    </button>\n  );\n}\n\nexport default function EasingEditorDemo() {\n  return <EasingEditor />;\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":{"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":{"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":{"shadow-raised":"var(--shadow-raised)"}},"categories":["playground"],"docs":"From ui lab: https://lab.xevrion.dev/lab/easing-editor"}