{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "demo-cursor",
  "title": "Demo Cursor",
  "description": "An animated demo cursor for React and shadcn, a scripted pointer that glides to real elements and presses them.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-text-swap.json",
    "https://eased.dev/r/eased-demo-core.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/demo-cursor/demo-cursor.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, animate, motion, useMotionValue, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport { TextSwap } from \"../_primitives/text-swap/text-swap\";\nimport {\n  DEMO_PRESS,\n  isPoint,\n  localRect,\n  resolveElement,\n  targetKey,\n  toClient,\n  type DemoPoint,\n  type DemoPressDetail,\n  type DemoTarget,\n} from \"../_primitives/demo-core/demo-core\";\nimport { duration, ease, enter, exit, exitFast, reducedFade, smooth, snappy, useSafeReducedMotion } from \"../lib/motion\";\n\nexport type DemoCursorProps = {\n  /**\n   * Where the cursor goes: an element by ref, a `data-demo` name inside the same stage, or a\n   * point in the stage's px. It glides there whenever this changes, and stays on an element\n   * as the layout moves. `null` hides the cursor.\n   */\n  to?: DemoTarget | null;\n  /** Where inside an element target the tip lands, as fractions of its box. Default `[0.5, 0.5]`. */\n  at?: readonly [number, number];\n  /**\n   * Hold the button down. The element under the tip depresses (0.97, 0.96 for icons, 0.985\n   * past 220px) and gets `data-demo-pressed`; moving while pressed is a drag, in a straight line.\n   */\n  pressed?: boolean;\n  /** Call the target's `click()` on release, so its own handler runs. Default false. */\n  click?: boolean;\n  /** A pill beside the pointer: a name, or what it is about to do. It swaps in place when it changes. */\n  label?: ReactNode;\n  /** A drag preview that travels under the pointer while it is set, e.g. a file being dragged. */\n  carry?: ReactNode;\n  /** Called when the cursor lands on its target. */\n  onArrive?: (target: Element | null) => void;\n  /** Called as it presses, with the point in the stage's px. */\n  onPress?: (point: DemoPoint, target: Element | null) => void;\n  className?: string;\n};\n\n/** How far a glide bows off the straight line, as a share of its length: a hand, not a ruler. */\nconst ARC = 0.06;\n/** The pointer's own press. The target presses at the Eased press scales; the glyph needs more to read at 20px. */\nconst GLYPH_PRESS = 0.9;\n/** The tip of the arrow in the glyph's own px. */\nconst TIP = { x: 2.7, y: 2 };\n\nconst pressDown = { duration: duration.instant, ease: ease.standard } as const;\n\n/** Whether an `at` lands inside its element's box. */\nconst inBox = ([x, y]: readonly [number, number]) => x >= 0 && x <= 1 && y >= 0 && y <= 1;\n\n/** The Eased press scale for an element of this size (rule 63); triggers that open a popup press by colour only (rule 64). */\nfunction pressScale(el: Element) {\n  if (el.hasAttribute(\"aria-haspopup\")) return 1;\n  const box = el.getBoundingClientRect();\n  const read = (name: string, fallback: number) => Number.parseFloat(getComputedStyle(el).getPropertyValue(name)) || fallback;\n  if (box.width > 220) return read(\"--eased-press-scale-wide\", 0.985);\n  if (box.width <= 48 && box.height <= 48) return read(\"--eased-press-scale-icon\", 0.96);\n  return read(\"--eased-press-scale\", 0.97);\n}\n\n/**\n * A scripted pointer for product demos over live UI. It glides on the smooth spring along a\n * shallow arc to an element (by ref or `data-demo` name) or a point, follows that element\n * through resizes, marks it `data-demo-hover` on arrival, and when pressed depresses it and\n * fires a press a Click Ripple can ring at. Place it inside the positioned element that holds\n * the UI (the stage); it covers the stage and never takes pointer input. Under reduced motion\n * it jumps to each target and nothing scales.\n */\nexport function DemoCursor({ to, at = [0.5, 0.5], pressed = false, click = false, label, carry, onArrive, onPress, className = \"\" }: DemoCursorProps) {\n  const reduced = useSafeReducedMotion();\n  const layer = useRef<HTMLDivElement>(null);\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const [shown, setShown] = useState(false);\n  // The element the tip is on, for press and hover, and whether the cursor is on the stage yet.\n  const current = useRef<Element | null>(null);\n  const placed = useRef(false);\n  const latest = useRef({ at, pressed, reduced, onArrive });\n  useLayoutEffect(() => {\n    latest.current = { at, pressed, reduced, onArrive };\n  });\n\n  const key = targetKey(to);\n  const [ax, ay] = at;\n\n  useLayoutEffect(() => {\n    const frame = layer.current;\n    if (!frame || to == null) {\n      placed.current = false;\n      setShown(false);\n      return;\n    }\n    const scope = frame.parentElement;\n    let glide: AnimationPlaybackControls | undefined;\n    let dest: DemoPoint | null = null;\n    let el: Element | null = null;\n    let landed = false;\n\n    const land = () => {\n      if (landed) return;\n      landed = true;\n      // A point outside the box (`at` beyond 0–1) is beside the element, not over it: no hover.\n      if (el && !latest.current.pressed && inBox(latest.current.at)) el.setAttribute(\"data-demo-hover\", \"\");\n      latest.current.onArrive?.(el);\n    };\n    const point = (): DemoPoint | null => {\n      if (isPoint(to)) return to;\n      el = resolveElement(to, scope);\n      if (!el) return null;\n      const r = localRect(el, frame);\n      return { x: r.x + r.width * ax, y: r.y + r.height * ay };\n    };\n    const place = (p: DemoPoint) => {\n      x.jump(p.x);\n      y.jump(p.y);\n    };\n\n    const go = (follow: boolean) => {\n      const p = point();\n      if (!p) return;\n      current.current = el;\n      // Already heading there (a resize that moved nothing): keep the glide and its velocity.\n      if (follow && dest && Math.hypot(p.x - dest.x, p.y - dest.y) < 0.5) return;\n      dest = p;\n      // First sight, reduced motion, or the layout moving under a resting cursor: be there.\n      if (!placed.current || latest.current.reduced || (follow && !glide)) {\n        glide?.stop();\n        glide = undefined;\n        place(p);\n        if (!placed.current) {\n          placed.current = true;\n          setShown(true);\n        }\n        land();\n        return;\n      }\n      glide?.stop();\n      const from = { x: x.get(), y: y.get() };\n      const dx = p.x - from.x;\n      const dy = p.y - from.y;\n      // A drag travels straight; a free move bows a little, like a wrist turning.\n      const bow = latest.current.pressed ? 0 : ARC;\n      glide = animate(0, 1, {\n        ...smooth,\n        onUpdate: (t) => {\n          const lift = Math.sin(Math.PI * t) * bow;\n          x.set(from.x + dx * t - dy * lift);\n          y.set(from.y + dy * t + dx * lift);\n        },\n        onComplete: () => {\n          glide = undefined;\n          land();\n        },\n      });\n    };\n\n    go(false);\n    // An element that mounts a frame late (a lazy chunk, a list mid-transition) is found once it lands.\n    const retry = el || isPoint(to) ? 0 : requestAnimationFrame(() => go(false));\n\n    // Stay on the target as the stage or the target resizes (a window resize, text reflow).\n    const ro = new ResizeObserver(() => go(true));\n    ro.observe(frame);\n    if (el) ro.observe(el);\n    return () => {\n      cancelAnimationFrame(retry);\n      ro.disconnect();\n      glide?.stop();\n      el?.removeAttribute(\"data-demo-hover\");\n    };\n    // `to` is keyed by `key`, so an inline point does not restart the glide every render.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [key, ax, ay, x, y]);\n\n  // Press and release: the target depresses on the press-down tween and springs back on snappy.\n  useEffect(() => {\n    if (!pressed) return;\n    const frame = layer.current;\n    const el = current.current;\n    if (!frame) return;\n    const point = { x: x.get(), y: y.get() };\n    const detail: DemoPressDetail = toClient(point, frame);\n    (el ?? frame.parentElement)?.dispatchEvent(new CustomEvent<DemoPressDetail>(DEMO_PRESS, { bubbles: true, detail }));\n    onPress?.(point, el);\n    if (!el) return;\n    el.removeAttribute(\"data-demo-hover\");\n    el.setAttribute(\"data-demo-pressed\", \"\");\n    const scale = reduced ? 1 : pressScale(el);\n    const down = scale === 1 ? undefined : animate(el, { scale }, pressDown);\n    return () => {\n      down?.stop();\n      el.removeAttribute(\"data-demo-pressed\");\n      if (el.isConnected && current.current === el && inBox(latest.current.at)) el.setAttribute(\"data-demo-hover\", \"\");\n      if (scale !== 1) animate(el, { scale: 1 }, snappy);\n      // A release, not an unmount mid-press: `latest` already holds the new props.\n      if (click && !latest.current.pressed && el instanceof HTMLElement) el.click();\n    };\n    // A press reads the callbacks as they are when it happens.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [pressed]);\n\n  return (\n    <div ref={layer} aria-hidden className={`pointer-events-none absolute inset-0 z-2 overflow-hidden ${className}`}>\n      <AnimatePresence>\n        {shown && (\n          <motion.div\n            className=\"absolute top-0 left-0\"\n            style={{ x, y }}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: reduced ? reducedFade : exit }}\n            transition={reduced ? reducedFade : enter}\n          >\n            <AnimatePresence>\n              {carry && (\n                <Pop key=\"carry\" reduced={reduced} className=\"absolute top-12 left-2 w-max\">\n                  {carry}\n                </Pop>\n              )}\n            </AnimatePresence>\n            <motion.svg\n              viewBox=\"0 0 24 28\"\n              width=\"20\"\n              height=\"23\"\n              className=\"absolute overflow-visible\"\n              style={{ left: -TIP.x, top: -TIP.y, transformOrigin: `${TIP.x}px ${TIP.y}px` }}\n              animate={{ scale: pressed && !reduced ? GLYPH_PRESS : 1 }}\n              transition={pressed ? pressDown : snappy}\n            >\n              <path\n                d=\"M3.2 2.4v19.7c0 .7.8 1 1.3.5l4.6-4.5 3.1 7.1c.2.5.8.7 1.3.5l2.4-1c.5-.2.7-.8.5-1.3l-3-7h6.4c.7 0 1-.8.5-1.3L4.5 1.9c-.5-.5-1.3-.2-1.3.5Z\"\n                className=\"fill-text stroke-surface\"\n                strokeWidth=\"1.5\"\n                strokeLinejoin=\"round\"\n              />\n            </motion.svg>\n            <AnimatePresence>\n              {label != null && label !== false && (\n                <Pop\n                  key=\"label\"\n                  reduced={reduced}\n                  y={-4}\n                  className=\"absolute top-5 left-3.5 inline-flex h-6 items-center rounded-pill border border-line bg-surface-raised px-2 text-ui-xs font-medium whitespace-nowrap text-text shadow-floating\"\n                >\n                  <TextSwap swapKey={typeof label === \"string\" || typeof label === \"number\" ? label : undefined}>{label}</TextSwap>\n                </Pop>\n              )}\n            </AnimatePresence>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/** What travels with the pointer (the label, a drag preview): in from 0.97 on snappy from its top-left, out faster. */\nfunction Pop({ reduced, y = 0, className, children }: { reduced: boolean; y?: number; className: string; children: ReactNode }) {\n  return (\n    <motion.span\n      className={className}\n      style={{ transformOrigin: \"0 0\" }}\n      initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.97, y }}\n      animate={{ opacity: 1, scale: 1, y: 0 }}\n      exit={reduced ? { opacity: 0, transition: reducedFade } : { opacity: 0, scale: 0.98, y: y / 2, transition: exitFast }}\n      transition={reduced ? reducedFade : { ...snappy, opacity: enter }}\n    >\n      {children}\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/demo-cursor/demo-cursor.tsx"
    }
  ],
  "meta": {
    "details": "A pointer for product demos over live UI, not a video. It glides on a spring along a shallow arc to an element by ref or data-demo name, stays on it through resizes, presses it so the element depresses, drags in a straight line, and wears an optional name pill. Under reduced motion it jumps to each target.",
    "industries": [
      "saas",
      "devtools",
      "ai"
    ],
    "releaseAt": "2026-10-26"
  },
  "docs": "Import DemoCursor from components/eased/demo-cursor/demo-cursor and useDemoTimeline from components/eased/_primitives/demo-core/demo-core. Put the cursor inside the positioned element that holds the UI; targets are refs or data-demo names inside it.",
  "type": "registry:ui"
}