{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cursor-gravity",
  "title": "Cursor Gravity",
  "description": "A rounded triangular cursor tugs a button out of the screen edge with pulsed elastic resistance.",
  "dependencies": ["remotion"],
  "files": [
    {
      "path": "registry/remocn/cursor-gravity/index.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Easing, useCurrentFrame, useVideoConfig } from \"remotion\";\n\nexport interface CursorGravityProps {\n  /** Custom content in a 300 × 88 reference-pixel slot. */\n  children?: ReactNode;\n  label?: string;\n  /** Strength of the elastic neck and recoil, 0–1.5. */\n  tension?: number;\n  cursorColor?: string;\n  color?: string;\n  textColor?: string;\n  backgroundColor?: string;\n  speed?: number;\n  className?: string;\n}\n\nexport const cursorGravityLength = 151;\nconst DURATION = 180;\nconst finite = (value: number, fallback: number) =>\n  Number.isFinite(value) ? value : fallback;\nconst clamp = (value: number) => Math.max(0, Math.min(1, value));\nconst mix = (a: number, b: number, p: number) => a + (b - a) * p;\nconst progress = (frame: number, start: number, end: number) =>\n  clamp((frame - start) / (end - start));\nconst ease = Easing.bezier(0.65, 0, 0.35, 1);\nconst pullEase = Easing.bezier(0.12, 0.8, 0.22, 1);\n\n/** Complete transition plus a one-second hold, in frames at 30 fps. */\nexport function getCursorGravityDuration({\n  speed = 1,\n}: Pick<CursorGravityProps, \"speed\"> = {}) {\n  const rate = Math.max(0, finite(speed, 1));\n  return rate === 0 ? 1 : Math.max(1, Math.ceil(DURATION / rate));\n}\n\n/** A pull advances sharply, pauses under load, then recoils before the next tug. */\nfunction pullDistance(t: number, strength: number) {\n  const keys = [\n    [48, 0],\n    [59, 116],\n    [64, 116],\n    [70, 116 - 30 * strength],\n    [83, 292],\n    [87, 292],\n    [94, 292 - 42 * strength],\n    [112, 772],\n  ];\n  if (t <= keys[0][0]) return 0;\n  for (let i = 1; i < keys.length; i++) {\n    const [start, a] = keys[i - 1];\n    const [end, b] = keys[i];\n    if (t <= end)\n      return mix(a, b, (b > a ? pullEase : ease)(progress(t, start, end)));\n  }\n  return 772;\n}\n\n/** Pure, resolution-aware pose. The right edge is the real composition boundary. */\nexport function getCursorGravityState(\n  frame: number,\n  {\n    width = 1280,\n    height = 720,\n    tension = 1,\n  }: { width?: number; height?: number; tension?: number } = {},\n) {\n  const w = Math.max(1, finite(width, 1280));\n  const h = Math.max(1, finite(height, 720));\n  const unit = Math.min(w / 1280, h / 720);\n  const t = Math.max(0, finite(frame, 0));\n  const strength = Math.max(0, Math.min(1.5, finite(tension, 1)));\n  const centerY = h * 0.5;\n  const finalLeft = w / 2 - 150 * unit;\n  const finalRight = w / 2 + 150 * unit;\n  const edgeTip = w - 24 * unit;\n  const distance = pullDistance(t, strength) / 772;\n  const extraction = distance * (edgeTip - (finalLeft - 14 * unit));\n  // The back of the button resists the first two tugs beyond the frame edge.\n  const releaseTime = progress(t, 94, 132);\n  // Closed-form damped spring stays continuous at fractional frames too.\n  const springRelease =\n    1 -\n    Math.exp(-7 * releaseTime) *\n      (Math.cos(10 * releaseTime) + 0.7 * Math.sin(10 * releaseTime));\n  const release = mix(springRelease, 1, ease(progress(t, 122, 132)));\n  const front = mix(w + 12 * unit, finalLeft, distance);\n  const right = mix(w + 312 * unit - distance * 70 * unit, finalRight, release);\n  const buttonWidth = Math.max(220 * unit, right - front);\n  const strain = clamp((buttonWidth / (300 * unit) - 1) / 1.3) * strength;\n  const wobble =\n    t < 112\n      ? 0\n      : Math.sin((t - 112) * 0.48) *\n        Math.exp(-(t - 112) * 0.14) *\n        5 *\n        unit *\n        strength *\n        (1 - progress(t, 132, 140));\n  const y = centerY + wobble;\n  const entry = ease(progress(t, 10, 39));\n  const approach = ease(progress(t, 39, 48));\n  const leave = ease(progress(t, 127, 151));\n  const holdTip = mix(w * 0.28, edgeTip - 80 * unit, entry);\n  const pullTip = edgeTip - extraction;\n  const cursorX =\n    t < 48 ? mix(holdTip, edgeTip, approach) : mix(pullTip, w * 0.25, leave);\n  const cursorY =\n    t < 39\n      ? mix(h + 110 * unit, centerY + 70 * unit, entry)\n      : t < 48\n        ? mix(centerY + 70 * unit, centerY, approach)\n        : mix(centerY, h + 110 * unit, leave);\n  // Triangle points toward the edge while pulling; turns toward its exit afterward.\n  const cursorAngle = t < 48 ? mix(-32, 0, approach) : mix(0, 115, leave);\n  const effortPulse = (\n    start: number,\n    peak: number,\n    hold: number,\n    end: number,\n  ) => ease(progress(t, start, peak)) * (1 - ease(progress(t, hold, end)));\n  const cursorEffort =\n    strength *\n    (0.8 * effortPulse(48, 58, 64, 70) +\n      effortPulse(70, 81, 87, 94) +\n      1.15 * effortPulse(94, 100, 104, 118));\n  return {\n    frame: t,\n    unit,\n    front,\n    right: front + buttonWidth,\n    buttonWidth,\n    centerY: y,\n    strain,\n    neckHeight: 88 * unit * (1 - 0.58 * strain),\n    bodyHeight: 88 * unit * (1 - 0.17 * strain),\n    cursorX,\n    cursorY,\n    cursorAngle,\n    cursorEffort,\n    attached: t >= 48 && t <= 121,\n    tetherOpacity: progress(t, 46, 49) * (1 - progress(t, 115, 122)),\n  };\n}\n\n/** The tip stays fixed; the rear bows backward as each tug builds effort. */\nexport function getGravityCursorPath(effort: number) {\n  const load = Math.max(0, Math.min(1.725, finite(effort, 0)));\n  const shoulder = -62 - 30 * load;\n  const rear = -70 - 40 * load;\n  const halfHeight = 26 - 4 * load;\n  return `M -5 -4 Q 3 0 -5 4\n    C -24 ${11.333 - load * 3} ${shoulder + 19} ${halfHeight - 7.333} ${shoulder} ${halfHeight}\n    Q ${rear} ${halfHeight + 4} ${rear} ${halfHeight - 6}\n    C ${rear} 4 ${rear - 18 * load} 8 ${rear - 18 * load} 0\n    C ${rear - 18 * load} -8 ${rear} -4 ${rear} ${-halfHeight + 6}\n    Q ${rear} ${-halfHeight - 4} ${shoulder} ${-halfHeight}\n    C ${shoulder + 19} ${-halfHeight + 7.333} -24 ${-11.333 + load * 3} -5 -4 Z`;\n}\n\n/** Rounded rectangle with a narrow leading edge: the pull deforms the surface. */\nexport function getGravityButtonPath(\n  state: ReturnType<typeof getCursorGravityState>,\n) {\n  const { buttonWidth: w, neckHeight, bodyHeight, unit } = state;\n  const a = neckHeight / 2;\n  const b = bodyHeight / 2;\n  const r = Math.min(25 * unit, a * 0.9);\n  const backRadius = 25 * unit;\n  return `M ${r} ${-a}\n    C ${w * 0.35} ${-a} ${w * 0.35} ${-b} ${w - backRadius} ${-b}\n    Q ${w} ${-b} ${w} ${-b + backRadius}\n    L ${w} ${b - backRadius} Q ${w} ${b} ${w - backRadius} ${b}\n    C ${w * 0.35} ${b} ${w * 0.35} ${a} ${r} ${a}\n    Q 0 ${a} 0 ${a - r} L 0 ${-a + r} Q 0 ${-a} ${r} ${-a} Z`;\n}\n\nexport function CursorGravity({\n  children,\n  label = \"Create something\",\n  tension = 1,\n  cursorColor = \"#fffaf0\",\n  color = \"#fffaf0\",\n  textColor = \"#32153c\",\n  backgroundColor = \"#a800b7\",\n  speed = 1,\n  className,\n}: CursorGravityProps) {\n  const frame = useCurrentFrame();\n  const { width, height, fps } = useVideoConfig();\n  const t = frame * (30 / fps) * Math.max(0, finite(speed, 1));\n  const state = getCursorGravityState(t, { width, height, tension });\n  const { unit } = state;\n  // Content travels with the material, without stretching the lettering.\n  const contentUnit = unit * Math.min(1, state.buttonWidth / (300 * unit));\n  const contentX = state.front + state.buttonWidth - 150 * contentUnit;\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        overflow: \"hidden\",\n        backgroundColor,\n        fontFamily: \"Arial, sans-serif\",\n      }}\n    >\n      <svg\n        width={width}\n        height={height}\n        viewBox={`0 0 ${width} ${height}`}\n        aria-hidden=\"true\"\n        style={{ position: \"absolute\", inset: 0 }}\n      >\n        <path\n          d={getGravityButtonPath(state)}\n          transform={`translate(${state.front} ${state.centerY})`}\n          fill={color}\n        />\n        <path\n          d={`M ${state.cursorX + 2 * unit} ${state.cursorY} Q ${mix(state.cursorX, state.front, 0.5)} ${state.centerY} ${state.front + 2 * unit} ${state.centerY}`}\n          fill=\"none\"\n          stroke={cursorColor}\n          strokeWidth={2 * unit}\n          strokeLinecap=\"round\"\n          opacity={state.tetherOpacity}\n        />\n      </svg>\n      <div\n        style={{\n          position: \"absolute\",\n          left: 0,\n          top: 0,\n          width: 300,\n          height: 88,\n          translate: `${contentX - 150 * contentUnit}px ${state.centerY - 44 * contentUnit}px`,\n          scale: contentUnit,\n          transformOrigin: \"top left\",\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          gap: 20,\n          color: textColor,\n          fontWeight: 600,\n          fontSize: 23,\n        }}\n      >\n        {children ?? (\n          <>\n            <span\n              style={{\n                maxWidth: 220,\n                overflow: \"hidden\",\n                whiteSpace: \"nowrap\",\n                textOverflow: \"ellipsis\",\n              }}\n            >\n              {label}\n            </span>\n            <svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n              <path\n                d=\"M5 12H19M13 6L19 12L13 18\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              />\n            </svg>\n          </>\n        )}\n      </div>\n      <svg\n        width={190 * unit}\n        height={70 * unit}\n        viewBox=\"-180 -35 190 70\"\n        role=\"img\"\n        aria-label=\"Rounded triangular cursor\"\n        style={{\n          position: \"absolute\",\n          left: 0,\n          top: 0,\n          translate: `${state.cursorX - 180 * unit}px ${state.cursorY - 35 * unit}px`,\n          transform: `rotate(${state.cursorAngle}deg)`,\n          transformOrigin: `${180 * unit}px ${35 * unit}px`,\n        }}\n      >\n        <path d={getGravityCursorPath(state.cursorEffort)} fill={cursorColor} />\n      </svg>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/cursor-gravity.tsx"
    }
  ],
  "type": "registry:component"
}
