{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "motion",
  "title": "Cuelark motion",
  "description": "The GSAP layer behind every animated component: the cue easing curves, the --cl-motion amplitude knob, reduced-motion handling, the press ping and overlay entrances.",
  "dependencies": [
    "@gsap/react",
    "gsap"
  ],
  "files": [
    {
      "path": "src/lib/motion.ts",
      "content": "\"use client\";\n\n/**\n * Cuelark motion: the GSAP layer every animated component shares.\n *\n *   --cl-motion   0 = off · 1 = default · 1.5 = expressive. Read from the nearest element, so a subtree\n *                 can calm down or liven up. `prefers-reduced-motion: reduce` always wins (scale 0).\n *\n * The scale multiplies amplitude (distance, overshoot, rotation), not duration, so expressive motion\n * still feels quick, and every tween collapses to an instant `set` when motion is off.\n */\nimport * as React from \"react\";\nimport gsap from \"gsap\";\nimport { CustomEase } from \"gsap/CustomEase\";\nimport { useGSAP } from \"@gsap/react\";\n\n// Register in the browser only: GSAP starts its ticker on registration, and server runtimes such as\n// Cloudflare Workers forbid timers at module scope. Nothing animates during SSR anyway.\nconst browser = typeof window !== \"undefined\";\nif (browser) gsap.registerPlugin(useGSAP, CustomEase);\n\n/** A strong ease-out: instant response, long settle (same curve as the `ease-cue` CSS token). */\nif (browser) CustomEase.create(\"cue\", \"M0,0 C0.23,1 0.32,1 1,1\");\n/** Lands slightly past the target and settles back: for things that snap into place. */\nif (browser) CustomEase.create(\"cue-snap\", \"M0,0 C0.25,1.4 0.4,1 1,1\");\n\nexport function motionScale(el?: Element | null): number {\n  if (typeof window === \"undefined\") return 0;\n  if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return 0;\n  const raw = getComputedStyle(el ?? document.documentElement).getPropertyValue(\"--cl-motion\");\n  const value = Number.parseFloat(raw);\n  return Number.isFinite(value) ? Math.max(0, value) : 1;\n}\n\n/** `gsap.to` that becomes `gsap.set` when motion is off for the target. */\nexport function cueTo(target: Element, vars: gsap.TweenVars, scale = motionScale(target)) {\n  return scale === 0 ? gsap.set(target, { ...vars, duration: 0, delay: 0 }) : gsap.to(target, { ease: \"cue\", duration: 0.35, ...vars });\n}\n\n/**\n * The signature press feedback: a thin ring that ripples out from the pointer and fades.\n * The host needs `position: relative` and `overflow: hidden`.\n */\nexport function cuePing(host: HTMLElement, event: { clientX: number; clientY: number }) {\n  const scale = motionScale(host);\n  if (scale === 0) return;\n  const rect = host.getBoundingClientRect();\n  const x = event.clientX ? event.clientX - rect.left : rect.width / 2;\n  const y = event.clientY ? event.clientY - rect.top : rect.height / 2;\n  const size = Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y)) * 2;\n  const ring = document.createElement(\"span\");\n  ring.setAttribute(\"aria-hidden\", \"true\");\n  ring.dataset.slot = \"ping\";\n  Object.assign(ring.style, {\n    position: \"absolute\",\n    left: `${x}px`,\n    top: `${y}px`,\n    width: `${size}px`,\n    height: `${size}px`,\n    marginLeft: `${-size / 2}px`,\n    marginTop: `${-size / 2}px`,\n    borderRadius: \"9999px\",\n    border: \"1.5px solid var(--signal, currentColor)\",\n    pointerEvents: \"none\",\n  });\n  host.appendChild(ring);\n  gsap.fromTo(ring, { scale: 0.1, opacity: 0.5 }, { scale: 1, opacity: 0, duration: 0.55 + 0.1 * scale, ease: \"cue\", onComplete: () => ring.remove() });\n}\n\nconst SIDE_OFFSET: Record<string, [number, number]> = { top: [0, 5], bottom: [0, -5], left: [5, 0], right: [-5, 0] };\n\n/**\n * Overlay entrance (tooltips, popovers, menus, selects): springs out of the side it is anchored to,\n * reading Radix's `data-side`. Pass it as a ref callback; exits stay on CSS so Radix can await them.\n */\nexport function popIn(node: HTMLElement | null) {\n  // Radix marks tooltips opened during the skip-delay window: those appear instantly, which makes a toolbar feel fast.\n  if (node?.dataset.state === \"instant-open\") return;\n  const [x, y] = SIDE_OFFSET[node?.dataset.side ?? \"bottom\"] ?? [0, 0];\n  enterOnce(node, (s) => ({ opacity: 0, scale: 1 - 0.04 * s, x: x * s, y: y * s }), { duration: 0.18 });\n}\n\nconst entered = new WeakSet<Element>();\nconst REST = { opacity: 1, x: 0, y: 0, xPercent: 0, yPercent: 0, scale: 1, rotate: 0, filter: \"none\" };\n\n/**\n * Entrance that runs exactly once per element and always lands on explicit resting values, then hands the\n * styles back to CSS (clearProps). Safe as a ref callback even if React attaches the ref more than once:\n * a second `gsap.from` would otherwise capture the half-faded state as its end value.\n */\nexport function enterOnce(node: Element | null, from: (scale: number) => gsap.TweenVars, vars: gsap.TweenVars = {}) {\n  if (!node || entered.has(node)) return;\n  entered.add(node);\n  const scale = motionScale(node);\n  if (!scale) return;\n  const start = from(scale);\n  const rest = Object.fromEntries(Object.keys(start).map((k) => [k, REST[k as keyof typeof REST] ?? 0]));\n  gsap.fromTo(node, start, { ...rest, duration: 0.4, ease: \"cue\", ...vars, clearProps: Object.keys(start).join(\",\") });\n}\n\n/** Staggered entrance for a set of children, with the same explicit resting values. */\nexport function enterChildren(targets: ArrayLike<Element>, from: gsap.TweenVars, vars: gsap.TweenVars = {}) {\n  if (!targets.length) return;\n  const rest = Object.fromEntries(Object.keys(from).map((k) => [k, REST[k as keyof typeof REST] ?? 0]));\n  gsap.fromTo(targets, from, { ...rest, duration: 0.4, ease: \"cue\", stagger: 0.04, ...vars, clearProps: Object.keys(from).join(\",\") });\n}\n\n/** Crop marks close in on an element (it needs the `cue-marks` utility): a registration \"lock\". */\nexport function marksIn(node: HTMLElement, delay = 0) {\n  const scale = motionScale(node);\n  if (!scale) return;\n  gsap.fromTo(node, { \"--cl-mark-gap\": `${4 + 14 * scale}px` }, { \"--cl-mark-gap\": \"4px\", duration: 0.6, ease: \"cue\", delay });\n}\n\n/**\n * Runs `onChange` whenever an attribute on the element changes (e.g. Radix's `data-state`), so motion\n * follows state no matter whether it changed from a click, the keyboard or a controlled prop.\n */\nexport function useAttributeCue<T extends HTMLElement>(ref: React.RefObject<T | null>, attribute: string, onChange: (value: string | null, el: T) => void) {\n  const callback = React.useRef(onChange);\n  callback.current = onChange;\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    let last = el.getAttribute(attribute);\n    const observer = new MutationObserver(() => {\n      const value = el.getAttribute(attribute);\n      if (value === last) return;\n      last = value;\n      callback.current(value, el);\n    });\n    observer.observe(el, { attributes: true, attributeFilter: [attribute] });\n    return () => observer.disconnect();\n  }, [ref, attribute]);\n}\n\n/** Combines a local ref with the one passed in props (React 19 passes `ref` as a regular prop). */\nexport function useMergedRef<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {\n  return React.useCallback((node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) (ref as React.RefObject<T | null>).current = node;\n    }\n  }, refs);\n}\n\nexport { gsap, useGSAP };\n\n/** A brief signal-colored halo around an element: \"this just changed\" (a tool finished, a task completed). */\nexport function flash(node: Element | null) {\n  if (!node || !motionScale(node)) return;\n  gsap.fromTo(\n    node,\n    { boxShadow: \"0 0 0 0px color-mix(in oklch, var(--signal) 55%, transparent)\" },\n    { boxShadow: \"0 0 0 6px color-mix(in oklch, var(--signal) 0%, transparent)\", duration: 0.7, ease: \"cue\", clearProps: \"boxShadow\" },\n  );\n}\n\n/** An elastic pop for status marks (checks, badges) as they appear. */\nexport function pop(node: Element | null, from = 0.5) {\n  if (!node || !motionScale(node)) return;\n  gsap.fromTo(node, { scale: from, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.5, ease: \"elastic.out(1.1, 0.5)\", clearProps: \"transform,opacity\" });\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:lib"
}