{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cursor-track",
  "title": "Cursor Track",
  "description": "Cursor click animation: a mouse pointer that moves and clicks over any UI. A synthetic cursor that walks a path of normalized waypoints over any children and pulses a click ring on arrival. A hand-drawn macOS-style arrow with an outline that keeps it readable over arbitrary footage, or a touch puck — for UI sims, and for recordings whose real cursor never made it into the file.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/cursor-track/index.tsx",
      "content": "// Cursor Track · snapcn — https://snapcn.dev/docs/screens/cursor-track\n\"use client\";\n\nimport { type ReactNode, useId } from \"react\";\nimport {\n  AbsoluteFill,\n  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme, withAlpha } from \"@/lib/snap-cn-ui\";\n\nexport type CursorVariant = \"arrow\" | \"dot\";\n\nexport interface CursorWaypoint {\n  /**\n   * Frame the cursor starts travelling TOWARDS this point, on this component's\n   * own clock (i.e. against `useCurrentFrame() * speed`). Absolute, not\n   * relative to the previous waypoint. Waypoints are sorted by `at`; ties break\n   * on array order and the last one wins.\n   *\n   * The FIRST waypoint has nothing to travel from, so it places the cursor\n   * rather than moving it — give it an off-frame `x` to have the cursor walk in.\n   */\n  at: number;\n  /**\n   * Destination, normalized: 0 -> 1 left to right across the composition,\n   * origin top-left. FRAME coordinates — a camera move inside a child\n   * `<ScreenRecording>` does not move the cursor. Values outside 0-1 are legal\n   * and mean off-frame, so a cursor entering from the left starts at `x: -0.1`.\n   */\n  x: number;\n  /** Destination, normalized: 0 -> 1 top to bottom. */\n  y: number;\n  /**\n   * Frames the travel takes. Default 14. The cursor then RESTS here until the\n   * next waypoint's `at` — there is no hold field, the hold is the gap. On the\n   * first waypoint there is no travel, so this is the fade-up window instead.\n   */\n  duration?: number;\n  /**\n   * Pulse a click ring on ARRIVAL, i.e. at `at + duration`. To click twice\n   * without moving, add a second waypoint at the same `x`/`y`.\n   */\n  click?: boolean;\n}\n\nexport interface CursorTrackProps {\n  /**\n   * What the cursor moves over — anything. Rendered full-bleed underneath it.\n   * Put the `<ScreenRecording>` (or the UI sim) in here, not this inside that:\n   * the cursor has to be the top layer to read as a cursor.\n   */\n  children?: ReactNode;\n  /**\n   * The path, in any order. Defaults to `DEMO_PATH` so the component is worth\n   * looking at with no props; pass `[]` and it renders nothing at all, i.e. it\n   * becomes a no-op wrapper.\n   */\n  path?: CursorWaypoint[];\n  /** `arrow` (default) is a pointer; `dot` is a touch/tap puck. */\n  variant?: CursorVariant;\n  /**\n   * Cursor height in px. Omit it and the component computes 3.9% of the\n   * composition height, so it stays the size a real cursor looks at any crop.\n   */\n  size?: number;\n  /**\n   * Cursor fill. Defaults to the theme's `foreground` — so pass `mode=\"dark\"`\n   * when the recording underneath is dark, and the cursor and its outline swap\n   * together.\n   */\n  color?: string;\n  /**\n   * The outline that keeps the cursor readable over arbitrary footage. Defaults\n   * to the theme's `background`, the opposite end of `color`.\n   */\n  outlineColor?: string;\n  /** The click ring. Defaults to the theme's `primary`. */\n  ringColor?: string;\n  /** Frames a click ring lives for. Default 14. */\n  clickFrames?: number;\n  /**\n   * Show the cursor parked at the first waypoint before its `at`. Default\n   * false — it fades in as the first move starts, which is what stops a cursor\n   * sitting inertly on frame 0.\n   */\n  showBefore?: boolean;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  /** Time multiplier. Every `at` is measured against `useCurrentFrame() * speed`. */\n  speed?: number;\n  className?: string;\n}\n\n// --- Constants -------------------------------------------------------------\n\n/**\n * Cursor travel, and deliberately NOT the camera curve. A pointer accelerates\n * and decelerates; a camera decelerate on a cursor reads robotic. Same curve\n * the notch morph and the showcase crane already use.\n */\nexport const CURSOR_EASE = Easing.inOut(Easing.cubic);\n\n/** Click ring growth. Opacity fades linearly under it. */\nexport const RING_EASE = Easing.out(Easing.cubic);\n\nexport const TRAVEL_FRAMES = 14;\nexport const CLICK_FRAMES = 14;\nexport const TAIL_FRAMES = 18;\n\n/** Cursor height as a fraction of the composition height, when `size` is omitted. */\nexport const CURSOR_HEIGHT_RATIO = 0.039;\n\n/** Ring radius at birth and at death, as multiples of the cursor height. */\nexport const RING_START = 0.18;\nexport const RING_END = 1.1;\n\n/**\n * The site's own pointer (app/globals.css): a rounded arrow, ink with a rim and\n * a soft shadow, in a 24-unit box with its hotspot — the tip — at (5.5, 5.5).\n * An SVG and not an emoji or a font glyph: a glyph is whatever face the\n * renderer happens to resolve, and it would carry the hinting problems the\n * motion-quality skill is about into the one element that moves every frame.\n */\nconst ARROW_VIEW = 24;\nconst ARROW_TIP = 5.5;\n/** The arrow's ink spans 14 of the 24 units; `size` is that span. */\nconst ARROW_INK = 14;\nconst ARROW_PATH = \"M5.5 5.5 19 11l-5.6 1.45a2 2 0 0 0-1.45 1.45L10.5 19.5z\";\n\n/** The `dot` puck's diameter, as a multiple of the cursor height. */\nconst DOT_RATIO = 0.62;\n\n/**\n * A short demo path: in from off-frame bottom-left, click, cross the frame,\n * click, then move off to the top-right. Carries the customizer preview and the\n * MCP skeleton, because `ControlType` has no array control to expose `path`\n * through — the same way `terminal-simulator` handles `lines`.\n */\nexport const DEMO_PATH: CursorWaypoint[] = [\n  { at: 0, x: -0.06, y: 0.86, duration: 6 },\n  { at: 8, x: 0.34, y: 0.44, duration: 22, click: true },\n  { at: 52, x: 0.68, y: 0.62, duration: 20, click: true },\n  { at: 96, x: 0.82, y: 0.28, duration: 18 },\n];\n\n// --- Pure helpers (unit-tested) -------------------------------------------\n\nexport interface CursorPose {\n  /** Position, normalized to the frame, origin top-left. */\n  x: number;\n  y: number;\n  /** Click-ring progress, 0 -> 1, or 0 when no ring is up. */\n  click: number;\n  /** 0 before the cursor has appeared, 1 once it has. */\n  opacity: number;\n}\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\n/**\n * Waypoints in the order they actually play. Same-`at` ties: the later array\n * entry wins and the earlier tied entries never display, so they can never act\n * as a leg's origin — the rule `useStateTransition` already uses for `Step`.\n */\nfunction ordered(path: CursorWaypoint[]): CursorWaypoint[] {\n  return path\n    .map((w, index) => ({ w, index }))\n    .sort((a, b) => a.w.at - b.w.at || a.index - b.index)\n    .filter((e, i, arr) => i === arr.length - 1 || arr[i + 1].w.at !== e.w.at)\n    .map((e) => e.w);\n}\n\n/**\n * The cursor's pose at `frame`, folding the path forward so a waypoint that\n * starts mid-travel takes over from where the cursor actually is. Pure: same\n * frame, same answer, whatever order Remotion renders in.\n *\n * ponytail: straight-line travel. If it reads robotic, add a per-leg arc here,\n * not a prop.\n */\nexport function cursorPoseAt(\n  frame: number,\n  path: CursorWaypoint[] = [],\n  clickFrames: number = CLICK_FRAMES,\n): CursorPose {\n  const moves = ordered(path);\n  if (moves.length === 0) return { x: 0.5, y: 0.5, click: 0, opacity: 0 };\n\n  // The cursor fades up over the first waypoint's own `duration`, so a track\n  // tunes its arrival without another prop. `showBefore` overrides this.\n  const first = moves[0];\n  const firstDur = first.duration ?? TRAVEL_FRAMES;\n  const opacity =\n    firstDur > 0\n      ? interpolate(frame, [first.at, first.at + firstDur], [0, 1], {\n          ...CLAMP,\n          easing: RING_EASE,\n        })\n      : frame >= first.at\n        ? 1\n        : 0;\n\n  // The first waypoint places the cursor — there is nothing to travel from.\n  let x = first.x;\n  let y = first.y;\n  for (let i = 1; i < moves.length; i++) {\n    const m = moves[i];\n    if (frame < m.at) break;\n    const dur = m.duration ?? TRAVEL_FRAMES;\n    // Truncated, never blended: a leg that overruns the next move's `at` stops\n    // contributing there, so the next leg starts from the pose actually on\n    // screen and the cursor never averages two destinations.\n    const until =\n      i + 1 < moves.length ? Math.min(frame, moves[i + 1].at) : frame;\n    const p =\n      dur > 0\n        ? interpolate(until, [m.at, m.at + dur], [0, 1], {\n            ...CLAMP,\n            easing: CURSOR_EASE,\n          })\n        : 1;\n    x += (m.x - x) * p;\n    y += (m.y - y) * p;\n  }\n\n  // The latest ring still alive wins, so two clicks close together do not stack\n  // two circles on one point. Progress is measured from the frame AFTER the\n  // click so `click === 0` reads as \"no ring up\" and the caller needs no second\n  // flag; one frame of a 14-frame ring is not something you can see.\n  let click = 0;\n  if (clickFrames > 0) {\n    for (const m of moves) {\n      if (!m.click) continue;\n      const at = m.at + (m.duration ?? TRAVEL_FRAMES);\n      if (frame >= at && frame < at + clickFrames) {\n        click = (frame - at + 1) / clickFrames;\n      }\n    }\n  }\n\n  return { x, y, click, opacity };\n}\n\n/**\n * Natural length of the cursor track — the last waypoint's `at + duration`,\n * plus `clickFrames` if it clicks, plus `TAIL_FRAMES`. A wrapper cannot see how\n * long its children run, so a scene's length is\n * `Math.max(childFrames, cursorTrackFrames(...))`.\n */\nexport function cursorTrackFrames(\n  path: CursorWaypoint[] = [],\n  clickFrames: number = CLICK_FRAMES,\n): number {\n  if (path.length === 0) return 0;\n  let last = 0;\n  for (const w of path) {\n    const end =\n      w.at + (w.duration ?? TRAVEL_FRAMES) + (w.click ? clickFrames : 0);\n    if (end > last) last = end;\n  }\n  return last + TAIL_FRAMES;\n}\n\n// --- Component -------------------------------------------------------------\n\nexport function CursorTrack({\n  children,\n  path = DEMO_PATH,\n  variant = \"arrow\",\n  size,\n  color,\n  outlineColor,\n  ringColor,\n  clickFrames = CLICK_FRAMES,\n  showBefore = false,\n  theme,\n  mode,\n  speed = 1,\n  className,\n}: CursorTrackProps) {\n  const frame = useCurrentFrame() * speed;\n  const t = useSnapCnTheme(theme, mode);\n  const { width, height } = useVideoConfig();\n  const isRendering = getRemotionEnvironment().isRendering;\n  const shadowId = `cursor-shadow-${useId().replace(/:/g, \"\")}`;\n\n  const h = size ?? height * CURSOR_HEIGHT_RATIO;\n  const ink = color ?? t.foreground;\n  const outline = outlineColor ?? t.background;\n  const ring = ringColor ?? t.primary;\n\n  const pose = cursorPoseAt(frame, path, clickFrames);\n  const opacity = showBefore ? 1 : pose.opacity;\n  const hidden = path.length === 0 || opacity <= 0;\n\n  // Ring: eased radius, linear opacity. It is a circle, not type, so scaling it\n  // has none of the glyph-snapping failure modes the motion-quality skill warns\n  // about — and it rides the cursor, so a ring never detaches from the pointer.\n  const ringRadius = interpolate(\n    pose.click,\n    [0, 1],\n    [h * RING_START, h * RING_END],\n    {\n      ...CLAMP,\n      easing: RING_EASE,\n    },\n  );\n  const ringOpacity = 1 - pose.click;\n  const ringStroke = Math.max(1.5, h * 0.07);\n\n  return (\n    <AbsoluteFill className={className}>\n      {children}\n      {!hidden && (\n        <AbsoluteFill style={{ pointerEvents: \"none\" }}>\n          {/* The one element that transforms every frame, so the one place\n              will-change belongs — and only outside a render, where a stale\n              raster is inherited across parallel tabs (motion-quality skill). */}\n          <div\n            style={{\n              position: \"absolute\",\n              left: 0,\n              top: 0,\n              opacity,\n              transform: `translate(${pose.x * width}px, ${pose.y * height}px)`,\n              ...(isRendering ? {} : { willChange: \"transform\" as const }),\n            }}\n          >\n            {pose.click > 0 && (\n              <div\n                style={{\n                  position: \"absolute\",\n                  left: -ringRadius,\n                  top: -ringRadius,\n                  width: ringRadius * 2,\n                  height: ringRadius * 2,\n                  borderRadius: \"50%\",\n                  border: `${ringStroke}px solid ${ring}`,\n                  backgroundColor: withAlpha(ring, 0.12),\n                  opacity: ringOpacity,\n                }}\n              />\n            )}\n            {variant === \"dot\" ? (\n              <div\n                style={{\n                  position: \"absolute\",\n                  left: (-h * DOT_RATIO) / 2,\n                  top: (-h * DOT_RATIO) / 2,\n                  width: h * DOT_RATIO,\n                  height: h * DOT_RATIO,\n                  borderRadius: \"50%\",\n                  backgroundColor: withAlpha(ink, 0.42),\n                  border: `${Math.max(1.5, h * 0.05)}px solid ${outline}`,\n                }}\n              />\n            ) : (\n              <svg\n                viewBox={`0 0 ${ARROW_VIEW} ${ARROW_VIEW}`}\n                width={(h * ARROW_VIEW) / ARROW_INK}\n                height={(h * ARROW_VIEW) / ARROW_INK}\n                style={{\n                  // The hotspot is the tip, not the box's corner.\n                  position: \"absolute\",\n                  left: (-h * ARROW_TIP) / ARROW_INK,\n                  top: (-h * ARROW_TIP) / ARROW_INK,\n                  display: \"block\",\n                  overflow: \"visible\",\n                }}\n              >\n                <title>Pointer</title>\n                <defs>\n                  <filter\n                    id={shadowId}\n                    x=\"-30%\"\n                    y=\"-30%\"\n                    width=\"160%\"\n                    height=\"160%\"\n                  >\n                    <feDropShadow\n                      dx={0}\n                      dy={1}\n                      stdDeviation={0.8}\n                      floodOpacity={0.35}\n                    />\n                  </filter>\n                </defs>\n                {/* Rim first, then the ink over it: the site cursor, stroke\n                    for stroke. The rim is what carries it over footage. */}\n                <g filter={`url(#${shadowId})`}>\n                  <path\n                    d={ARROW_PATH}\n                    fill={outline}\n                    stroke={outline}\n                    strokeWidth={5}\n                    strokeLinejoin=\"round\"\n                  />\n                  <path\n                    d={ARROW_PATH}\n                    fill={ink}\n                    stroke={ink}\n                    strokeWidth={2}\n                    strokeLinejoin=\"round\"\n                  />\n                </g>\n              </svg>\n            )}\n          </div>\n        </AbsoluteFill>\n      )}\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/cursor-track.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/screens/cursor-track?ref=cli — pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette and a bare Remotion render is black. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
