{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cursor-frame",
  "title": "Cursor Frame",
  "description": "A selection frame with handles and a size badge is dragged around a two-line headline.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/cursor-frame/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Geist\";\nimport { loadFont as loadMono } from \"@remotion/google-fonts/GeistMono\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst face = loadFont(\"normal\", { weights: [\"400\"], subsets: [\"latin\"] });\nconst mono = loadMono(\"normal\", { weights: [\"500\"], subsets: [\"latin\"] });\n\nexport interface CursorFrameProps {\n  /** The headline set inside the text frame. */\n  title?: string;\n  /** Selection colour: frame, handles and the size badge. */\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\nconst THEMES = {\n  light: { bg: \"#FAFAF9\", fg: \"#0E0E0D\", handle: \"#FFFFFF\" },\n  dark: { bg: \"#0A0A0A\", fg: \"#F2F2F0\", handle: \"#FFFFFF\" },\n} as const;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_IN_OUT = Easing.bezier(0.65, 0, 0.35, 1);\nconst EASE_DRAG = Easing.bezier(0.45, 0, 0.2, 1);\nconst clamp = { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" } as const;\n\n// Beats, in frames at 30fps (before `speed`).\nconst ENTER = 0;\nconst ENTER_LEN = 24;\nconst PRESS = 25;\nconst DRAG = 28;\nconst DRAG_END = 70;\nconst GRAB = 80;\nconst RESIZE = 86;\nconst RESIZE_END = 134;\nconst RELEASE = 140;\nconst CLEAR = 150;\nconst RECENTRE = 148;\nconst RECENTRE_LEN = 36;\nconst WORD_IN = 12;\nconst LINE_HEIGHT = 1.1;\nconst WRAP_IN = 11;\n/** How far a wrapping word drifts out and slides in, in em. */\nconst WRAP_SLIDE = 0.3;\n\ninterface Metrics {\n  words: number[];\n  space: number;\n}\n\ninterface Placed {\n  x: number;\n  y: number;\n}\n\n/** Greedy line breaking at a given box width. */\nfunction breakLines(m: Metrics, boxW: number, lineH: number) {\n  const pos: Placed[] = [];\n  let x = 0;\n  let line = 0;\n  let maxW = 0;\n  m.words.forEach((w, i) => {\n    if (i > 0 && x + m.space + w > boxW + 0.5) {\n      line++;\n      x = 0;\n    } else if (i > 0) {\n      x += m.space;\n    }\n    pos.push({ x, y: line * lineH });\n    x += w;\n    maxW = Math.max(maxW, x);\n  });\n  return { pos, lines: line + 1, maxW };\n}\n\n/** The narrowest two-line box: the most balanced split of the words. */\nfunction twoLineWidth(m: Metrics) {\n  let best = Number.POSITIVE_INFINITY;\n  for (let k = 1; k < m.words.length; k++) {\n    const a = m.words.slice(0, k);\n    const b = m.words.slice(k);\n    const wa = a.reduce((s, w) => s + w, 0) + (a.length - 1) * m.space;\n    const wb = b.reduce((s, w) => s + w, 0) + (b.length - 1) * m.space;\n    best = Math.min(best, Math.max(wa, wb));\n  }\n  return best;\n}\n\nfunction ArrowCursor({ size }: { size: number }) {\n  return (\n    <svg\n      viewBox=\"-2 -2 20 30\"\n      width={(size * 20) / 30}\n      height={size}\n      style={{ display: \"block\", overflow: \"visible\" }}\n      aria-hidden\n    >\n      <path\n        d=\"M0 0 L0 21.6 L5 16.9 L8.5 25.1 L12 23.6 L8.6 15.5 L15.4 15.5 Z\"\n        fill=\"#000\"\n        stroke=\"#fff\"\n        strokeWidth={1.7}\n        strokeLinejoin=\"round\"\n        paintOrder=\"stroke\"\n      />\n    </svg>\n  );\n}\n\nfunction ResizeCursor({ size }: { size: number }) {\n  return (\n    <svg\n      viewBox=\"-15 -15 30 30\"\n      width={size}\n      height={size}\n      style={{ display: \"block\", overflow: \"visible\" }}\n      aria-hidden\n    >\n      <path\n        d=\"M-10.5 0 L-4.6 -5.9 L-4.6 -2.1 L4.6 -2.1 L4.6 -5.9 L10.5 0 L4.6 5.9 L4.6 2.1 L-4.6 2.1 L-4.6 5.9 Z\"\n        transform=\"rotate(45)\"\n        fill=\"#000\"\n        stroke=\"#fff\"\n        strokeWidth={1.7}\n        strokeLinejoin=\"round\"\n        paintOrder=\"stroke\"\n      />\n    </svg>\n  );\n}\n\nexport function CursorFrame({\n  title = \"Launch videos, in code.\",\n  accent = \"#3B5BFD\",\n  theme = \"light\",\n  speed = 1,\n}: CursorFrameProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width, height } = useVideoConfig();\n  const t = THEMES[theme];\n  const words = title.split(/\\s+/).filter(Boolean);\n  const u = width / 1280;\n\n  const fontSize = Math.round(width * 0.068);\n  const lineH = fontSize * LINE_HEIGHT;\n\n  const wordRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const spaceRef = useRef<HTMLSpanElement>(null);\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  const [handle] = useState(() => delayRender(\"cursor-frame: measuring\"));\n  const released = useRef(false);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure when the copy or size changes\n  useLayoutEffect(() => {\n    let live = true;\n    face\n      .waitUntilDone()\n      .catch(() => undefined)\n      .then(() => document.fonts.ready)\n      .then(() => {\n        if (!live) return;\n        const space = spaceRef.current;\n        if (space) {\n          const next: Metrics = {\n            words: wordRefs.current\n              .slice(0, words.length)\n              .map((el) => el?.offsetWidth ?? 0),\n            space: space.offsetWidth,\n          };\n          setMetrics((prev) =>\n            prev &&\n            prev.space === next.space &&\n            prev.words.join() === next.words.join()\n              ? prev\n              : next,\n          );\n        }\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [title, fontSize, handle]);\n\n  const m = metrics ?? { words: words.map(() => 0), space: 0 };\n  const wide = breakLines(m, Number.POSITIVE_INFINITY, lineH);\n  const W1 = wide.maxW + fontSize * 0.04;\n  const W2 = twoLineWidth(m) + fontSize * 0.04;\n  const H1 = lineH;\n  const x0 = width / 2 - W1 / 2;\n  const y0 = height / 2 - H1 / 2;\n\n  // Box width the hand asks for at frame f (the corner follows the cursor).\n  const dragX = (f: number) =>\n    interpolate(f, [DRAG, DRAG_END], [0, 1], { ...clamp, easing: EASE_DRAG });\n  const dragY = (f: number) =>\n    interpolate(f, [DRAG, DRAG + 14], [0, 1], { ...clamp, easing: EASE_OUT });\n  const boxW = (f: number) =>\n    f < RESIZE\n      ? W1 * dragX(f)\n      : interpolate(f, [RESIZE, RESIZE_END], [W1, W2], {\n          ...clamp,\n          easing: EASE_IN_OUT,\n        });\n\n  // Reflow: a word that keeps its line slides to its new slot on a critically\n  // damped spring. A word that wraps leaves a fading ghost drifting on at the\n  // old line end and slides in fresh at the start of the next, so no two\n  // words ever cross.\n  const settleAt = (f: number) => {\n    const start = breakLines(m, W1, lineH);\n    const pos = start.pos.map((p) => ({\n      ...p,\n      vx: 0,\n      wrap: -1e4,\n      fromX: 0,\n      fromY: 0,\n    }));\n    let h = lineH * start.lines;\n    let vh = 0;\n    const w = 0.6;\n    const wh = 0.6;\n    const end = Math.min(f, RESIZE_END + 60);\n    for (let s = RESIZE; s <= end; s++) {\n      const target = breakLines(m, boxW(s), lineH);\n      pos.forEach((p, i) => {\n        const tp = target.pos[i] ?? p;\n        if (Math.abs(tp.y - p.y) > 0.5) {\n          p.wrap = s;\n          p.fromX = p.x;\n          p.fromY = p.y;\n          p.x = tp.x;\n          p.y = tp.y;\n          p.vx = 0;\n        }\n        p.vx += (tp.x - p.x) * w * w - 2 * w * p.vx;\n        p.x += p.vx;\n      });\n      vh += (target.lines * lineH - h) * wh * wh - 2 * wh * vh;\n      h += vh;\n    }\n    return { pos, h };\n  };\n  const fA = Math.floor(frame);\n  const a = settleAt(fA);\n  const b = settleAt(fA + 1);\n  const fr = frame - fA;\n  const wordPos = a.pos.map((p, i) => {\n    const q = b.pos[i];\n    return q && q.wrap === p.wrap ? { ...p, x: p.x + (q.x - p.x) * fr } : p;\n  });\n  const settledH = a.h + (b.h - a.h) * fr;\n\n  const fw = boxW(frame);\n  const fh = frame < RESIZE ? H1 * dragY(frame) : settledH;\n\n  // When does the growing frame first pass each word's centre?\n  const wordIn = wide.pos.map((p, i) => {\n    const centre = p.x + (m.words[i] ?? 0) * 0.5;\n    for (let f = DRAG; f <= DRAG_END; f++) if (boxW(f) >= centre) return f - 4;\n    return DRAG_END;\n  });\n\n  // Cursor: glides in, presses, drags the corner out, swaps to the resize\n  // arrows, drags the corner in, lets go and drifts off.\n  const enter = interpolate(frame, [ENTER, ENTER + ENTER_LEN], [0, 1], {\n    ...clamp,\n    easing: EASE_IN_OUT,\n  });\n  const leave = interpolate(frame, [RELEASE + 6, RELEASE + 34], [0, 1], {\n    ...clamp,\n    easing: EASE_IN_OUT,\n  });\n  let cx = x0;\n  let cy = y0;\n  if (frame < DRAG) {\n    cx = interpolate(enter, [0, 1], [x0 + width * 0.16, x0]);\n    cy = interpolate(enter, [0, 1], [y0 + height * 0.2, y0]);\n  } else {\n    cx = x0 + fw;\n    cy = y0 + fh;\n  }\n  cx += leave * width * 0.1;\n  cy += leave * height * 0.12;\n  const cursorOpacity =\n    interpolate(frame, [ENTER, ENTER + 8], [0, 1], clamp) *\n    interpolate(frame, [RELEASE + 14, RELEASE + 34], [1, 0], clamp);\n  const press =\n    interpolate(frame, [PRESS, PRESS + 3], [0, 1], clamp) -\n    interpolate(frame, [DRAG_END, DRAG_END + 3], [0, 1], clamp) +\n    interpolate(frame, [RESIZE - 3, RESIZE], [0, 1], clamp) -\n    interpolate(frame, [RESIZE_END + 4, RESIZE_END + 7], [0, 1], clamp);\n  const resizeIcon =\n    interpolate(frame, [GRAB, GRAB + 3], [0, 1], clamp) -\n    interpolate(frame, [RELEASE, RELEASE + 3], [0, 1], clamp);\n  const cursorSize = 30 * u * (1 - press * 0.06);\n\n  // Selection chrome: appears with the drag, clears once the text is set.\n  const chromeIn = interpolate(frame, [DRAG, DRAG + 4], [0, 1], clamp);\n  const clear = interpolate(frame, [CLEAR, CLEAR + 14], [0, 1], {\n    ...clamp,\n    easing: EASE_OUT,\n  });\n  const chrome = chromeIn * (1 - clear);\n  const handlesIn = interpolate(frame, [DRAG + 2, DRAG + 12], [0, 1], {\n    ...clamp,\n    easing: EASE_OUT,\n  });\n  const handleScale = (0.5 + 0.5 * handlesIn) * (1 - clear * 0.5);\n\n  // Once the frame is gone, the text block glides to the optical centre.\n  const re = interpolate(frame, [RECENTRE, RECENTRE + RECENTRE_LEN], [0, 1], {\n    ...clamp,\n    easing: EASE_IN_OUT,\n  });\n  const finalH = breakLines(m, W2, lineH).lines * lineH;\n  const shiftX = re * (W1 - W2) * 0.5;\n  const shiftY = re * (H1 - finalH) * 0.5;\n\n  const stroke = Math.max(1, 1.5 * u);\n  const hs = 9 * u;\n  const corners = [\n    [0, 0],\n    [fw, 0],\n    [0, fh],\n    [fw, fh],\n  ];\n  const badgeSize = 13 * u;\n\n  return (\n    <AbsoluteFill style={{ background: t.bg, overflow: \"hidden\" }}>\n      {/* measuring row: never shown */}\n      <div\n        style={{\n          position: \"absolute\",\n          visibility: \"hidden\",\n          whiteSpace: \"pre\",\n          fontFamily: face.fontFamily,\n          fontSize,\n          fontWeight: 400,\n          letterSpacing: \"-0.035em\",\n        }}\n      >\n        {words.map((w, i) => (\n          <span\n            key={i}\n            ref={(el) => {\n              wordRefs.current[i] = el;\n            }}\n            style={{ display: \"inline-block\" }}\n          >\n            {w}\n          </span>\n        ))}\n        <span ref={spaceRef} style={{ display: \"inline-block\" }}>\n          {\" \"}\n        </span>\n      </div>\n\n      <div\n        style={{\n          position: \"absolute\",\n          left: x0 + shiftX,\n          top: y0 + shiftY,\n          width: 0,\n          height: 0,\n          opacity: metrics ? 1 : 0,\n        }}\n      >\n        {words.map((w, i) => {\n          const p = interpolate(\n            frame,\n            [wordIn[i] ?? DRAG, (wordIn[i] ?? DRAG) + WORD_IN],\n            [0, 1],\n            { ...clamp, easing: EASE_OUT },\n          );\n          const pos = wordPos[i] ?? {\n            x: 0,\n            y: 0,\n            wrap: -1e4,\n            fromX: 0,\n            fromY: 0,\n          };\n          const wrapP = interpolate(\n            frame,\n            [pos.wrap + 4, pos.wrap + 4 + WRAP_IN],\n            [0, 1],\n            {\n              ...clamp,\n              easing: EASE_OUT,\n            },\n          );\n          const ghostP = interpolate(\n            frame,\n            [pos.wrap, pos.wrap + WRAP_IN],\n            [0, 1],\n            {\n              ...clamp,\n              easing: EASE_OUT,\n            },\n          );\n          const style = {\n            position: \"absolute\",\n            whiteSpace: \"pre\",\n            fontFamily: face.fontFamily,\n            fontSize,\n            fontWeight: 400,\n            letterSpacing: \"-0.035em\",\n            lineHeight: LINE_HEIGHT,\n            color: t.fg,\n          } as const;\n          const inP = p * wrapP;\n          return (\n            <span key={i}>\n              {ghostP < 1 ? (\n                <span\n                  style={{\n                    ...style,\n                    left: pos.fromX + ghostP * fontSize * WRAP_SLIDE,\n                    top: pos.fromY,\n                    opacity: 1 - ghostP,\n                    filter: `blur(${ghostP * 6}px)`,\n                  }}\n                >\n                  {w}\n                </span>\n              ) : null}\n              <span\n                style={{\n                  ...style,\n                  left: pos.x,\n                  top: pos.y,\n                  opacity: inP,\n                  filter: inP < 1 ? `blur(${(1 - inP) * 8}px)` : undefined,\n                  transform: `translateY(${(1 - p) * fontSize * 0.12 - (1 - wrapP) * fontSize * 0.22}px)`,\n                }}\n              >\n                {w}\n              </span>\n            </span>\n          );\n        })}\n\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: 0,\n            width: fw,\n            height: fh,\n            boxShadow: `inset 0 0 0 ${stroke}px ${accent}`,\n            opacity: chrome,\n          }}\n        />\n        {corners.map(([hx, hy]) => (\n          <div\n            key={`${hx === 0 ? \"l\" : \"r\"}${hy === 0 ? \"t\" : \"b\"}`}\n            style={{\n              position: \"absolute\",\n              left: hx - hs / 2,\n              top: hy - hs / 2,\n              width: hs,\n              height: hs,\n              boxSizing: \"border-box\",\n              background: t.handle,\n              border: `${stroke}px solid ${accent}`,\n              borderRadius: 1.5 * u,\n              opacity: chrome * handlesIn,\n              transform: `scale(${handleScale})`,\n            }}\n          />\n        ))}\n        <div\n          style={{\n            position: \"absolute\",\n            left: fw / 2,\n            top: fh + 10 * u,\n            transform: \"translateX(-50%)\",\n            padding: `${3 * u}px ${6 * u}px`,\n            borderRadius: 4 * u,\n            background: accent,\n            color: \"#FFFFFF\",\n            fontFamily: mono.fontFamily,\n            fontWeight: 500,\n            fontSize: badgeSize,\n            lineHeight: 1.2,\n            letterSpacing: \"0.01em\",\n            fontVariantNumeric: \"tabular-nums\",\n            whiteSpace: \"pre\",\n            opacity: chrome * handlesIn,\n          }}\n        >\n          {`${Math.round(fw)} × ${Math.round(fh)}`}\n        </div>\n      </div>\n\n      <div\n        style={{\n          position: \"absolute\",\n          left: cx + shiftX,\n          top: cy + shiftY,\n          opacity: metrics ? cursorOpacity : 0,\n          filter: \"drop-shadow(0 1px 1.5px rgba(0,0,0,0.28))\",\n        }}\n      >\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: 0,\n            opacity: 1 - resizeIcon,\n          }}\n        >\n          <ArrowCursor size={cursorSize} />\n        </div>\n        <div\n          style={{\n            position: \"absolute\",\n            left: -cursorSize * 0.5,\n            top: -cursorSize * 0.5,\n            opacity: resizeIcon,\n          }}\n        >\n          <ResizeCursor size={cursorSize} />\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/cursor-frame.tsx"
    }
  ],
  "type": "registry:component"
}
