{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scissors-cut",
  "title": "Scissors Cut",
  "description": "Scissors cut a word out of a line and another slides into the gap: 'Launch today.'.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/scissors-cut/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Onest\";\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: [\"300\"], subsets: [\"latin\"] });\n\nexport interface ScissorsCutProps {\n  /** Text before the cut-out. Keep the trailing space. */\n  before?: string;\n  /** The words the scissors cut out. */\n  cut?: string;\n  /** Text after the cut-out. */\n  after?: string;\n  /** What slides into the gap. */\n  replacement?: string;\n  /** Scissor handles and the replacement word. */\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\nconst THEMES = {\n  dark: {\n    bg: \"#0A0A0A\",\n    fg: \"#F2F2F0\",\n    blade: \"#D9D9D5\",\n    dash: \"rgba(242,242,240,0.5)\",\n    edge: \"rgba(242,242,240,0.62)\",\n  },\n  light: {\n    bg: \"#FAFAF9\",\n    fg: \"#0E0E0D\",\n    blade: \"#3A3A37\",\n    dash: \"rgba(14,14,13,0.45)\",\n    edge: \"rgba(14,14,13,0.55)\",\n  },\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_CUT = Easing.bezier(0.45, 0, 0.55, 1);\nconst EASE_IN = Easing.bezier(0.55, 0, 0.85, 0.35);\nconst clamp = { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" } as const;\n\n// Beats, in frames at 30fps (before `speed`).\nconst IN = 0;\nconst IN_STAGGER = 1.5;\nconst ROOM = 14;\nconst ROOM_LEN = 20;\nconst DASH = 24;\nconst DASH_LEN = 24;\nconst SC_IN = 30;\nconst SC_IN_LEN = 24;\nconst CUT = 56;\nconst CUT_LEN = 62;\nconst SC_OUT = 120;\nconst SC_OUT_LEN = 22;\nconst LIFT = 120;\nconst LIFT_LEN = 8;\nconst FALL_LEN = 26;\nconst REFLOW = 142;\nconst REFLOW_LEN = 22;\nconst REPL = 152;\nconst REPL_LEN = 24;\nconst CLOSE = 180;\nconst CLOSE_LEN = 22;\n\nconst LINE_HEIGHT = 1.2;\n/** Scissor blade length, in em. */\nconst SCISSORS = 1;\n/** Travel per snip, in em. */\nconst SNIP = 0.5;\n\ninterface Row {\n  xs: number[];\n  ws: number[];\n  w: number;\n}\n\ninterface Layout {\n  a: Row;\n  b: Row;\n  baseline: number;\n}\n\nfunction sameLayout(a: Layout | null, b: Layout) {\n  return (\n    a !== null &&\n    a.a.xs.join() === b.a.xs.join() &&\n    a.b.xs.join() === b.b.xs.join() &&\n    a.baseline === b.baseline\n  );\n}\n\ninterface Rect {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\n/** Corner distances along the cut, which starts mid-way along the top edge and runs clockwise. */\nfunction cornersOf(r: Rect) {\n  return [r.w / 2, r.w / 2 + r.h, (3 * r.w) / 2 + r.h, (3 * r.w) / 2 + 2 * r.h];\n}\n\n/** Point at distance `s` along the cut. */\nfunction along(r: Rect, s: number) {\n  const P = 2 * (r.w + r.h);\n  const d = (((s + r.w / 2) % P) + P) % P;\n  if (d <= r.w) return { x: r.x + d, y: r.y };\n  if (d <= r.w + r.h) return { x: r.x + r.w, y: r.y + (d - r.w) };\n  if (d <= 2 * r.w + r.h)\n    return { x: r.x + r.w - (d - r.w - r.h), y: r.y + r.h };\n  return { x: r.x, y: r.y + r.h - (d - 2 * r.w - r.h) };\n}\n\n/** Points from `s0` to `s1` along the cut, corners included, as an SVG points list. */\nfunction span(r: Rect, s0: number, s1: number) {\n  const pts = [along(r, s0)];\n  for (const c of cornersOf(r)) if (c > s0 && c < s1) pts.push(along(r, c));\n  pts.push(along(r, s1));\n  return pts.map((p) => `${p.x},${p.y}`).join(\" \");\n}\n\n/** Heading (deg) that turns smoothly through each corner over `turn` px of travel. */\nfunction heading(r: Rect, s: number, turn: number) {\n  let a = 0;\n  for (const c of cornersOf(r))\n    a += 90 * EASE_IN_OUT(Math.max(0, Math.min(1, (s - c + turn / 2) / turn)));\n  return a;\n}\n\n/** Flat scissors pointing along +x, pivot at the origin, blade length 1. */\nfunction Scissors({\n  open,\n  blade,\n  ring,\n  bg,\n}: {\n  open: number;\n  blade: string;\n  ring: string;\n  bg: string;\n}) {\n  const half = (sign: 1 | -1) => (\n    <g transform={`rotate(${sign * open})`}>\n      <path\n        d={`M -0.12 ${0.02 * sign} C 0.2 ${0.11 * sign}, 0.68 ${0.07 * sign}, 1 0 L 0.05 ${-0.012 * sign} Z`}\n        fill={blade}\n      />\n      <path\n        d={`M -0.02 0 C -0.2 ${-0.02 * sign}, -0.34 ${-0.1 * sign}, -0.44 ${-0.17 * sign}`}\n        fill=\"none\"\n        stroke={blade}\n        strokeWidth={0.075}\n        strokeLinecap=\"round\"\n      />\n      <ellipse\n        cx={-0.6}\n        cy={-0.24 * sign}\n        rx={0.17}\n        ry={0.135}\n        transform={`rotate(${-18 * sign} -0.6 ${-0.24 * sign})`}\n        fill=\"none\"\n        stroke={ring}\n        strokeWidth={0.07}\n      />\n    </g>\n  );\n  return (\n    <>\n      {half(1)}\n      {half(-1)}\n      <circle r={0.045} fill={bg} stroke={blade} strokeWidth={0.03} />\n    </>\n  );\n}\n\nexport function ScissorsCut({\n  before = \"Launch \",\n  cut = \"next month\",\n  after = \".\",\n  replacement = \"today\",\n  accent = \"#3B5BFD\",\n  theme = \"dark\",\n  speed = 1,\n}: ScissorsCutProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width, height } = useVideoConfig();\n  const t = THEMES[theme];\n\n  const em = Math.round(width * 0.08);\n  const textStyle = {\n    fontFamily: face.fontFamily,\n    fontSize: em,\n    fontWeight: 300,\n    letterSpacing: \"-0.035em\",\n    lineHeight: LINE_HEIGHT,\n    whiteSpace: \"pre\",\n  } as const;\n\n  const pre = Array.from(before);\n  const mid = Array.from(cut);\n  const repl = Array.from(replacement);\n  const post = Array.from(after);\n  const rowA = [...pre, ...mid, ...post];\n  const rowB = [...pre, ...repl, ...post];\n\n  const aRef = useRef<HTMLDivElement>(null);\n  const bRef = useRef<HTMLDivElement>(null);\n  const baseRef = useRef<HTMLSpanElement>(null);\n  const [layout, setLayout] = useState<Layout | null>(null);\n  const [handle] = useState(() => delayRender(\"scissors-cut: 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 a = aRef.current;\n        const b = bRef.current;\n        const base = baseRef.current;\n        if (a && b && base) {\n          const read = (row: HTMLDivElement): Row => {\n            const r = row.getBoundingClientRect();\n            const scale = r.width / row.offsetWidth || 1;\n            const spans = Array.from(row.querySelectorAll(\"[data-l]\"));\n            return {\n              xs: spans.map(\n                (el) => (el.getBoundingClientRect().left - r.left) / scale,\n              ),\n              ws: spans.map((el) => el.getBoundingClientRect().width / scale),\n              w: row.offsetWidth,\n            };\n          };\n          const next: Layout = {\n            a: read(a),\n            b: read(b),\n            baseline: base.offsetTop,\n          };\n          setLayout((prev) => (sameLayout(prev, next) ? prev : next));\n        }\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [before, cut, after, replacement, em, handle]);\n\n  const L = layout;\n  const wA = L?.a.w ?? 0;\n  const wB = L?.b.w ?? 0;\n  const leftA = width / 2 - wA / 2;\n  const leftB = width / 2 - wB / 2;\n  const baseY = height / 2 + em * 0.3;\n  const rowTop = baseY - (L?.baseline ?? em);\n\n  const reflow = interpolate(frame, [REFLOW, REFLOW + REFLOW_LEN], [0, 1], {\n    ...clamp,\n    easing: EASE_IN_OUT,\n  });\n\n  const iA = pre.length;\n  const iZ = pre.length + mid.length - 1;\n  const jZ = pre.length + repl.length - 1;\n  const ax = (i: number) => leftA + (L?.a.xs[i] ?? 0);\n  const bx = (i: number) => leftB + (L?.b.xs[i] ?? 0);\n\n  // Room: the neighbours step aside so the cut line has air, and close up\n  // again once the new word is in.\n  const padX = em * 0.14;\n  const need = padX + em * 0.2;\n  const room = (row: Row | undefined, chars: string[], end: number) => {\n    if (!row) return { l: 0, r: 0, c: 0 };\n    let k = iA - 1;\n    while (k >= 0 && chars[k] === \" \") k--;\n    const gapL =\n      k < 0 ? need : (row.xs[iA] ?? 0) - (row.xs[k] ?? 0) - (row.ws[k] ?? 0);\n    const gapR =\n      end + 1 >= chars.length\n        ? need\n        : (row.xs[end + 1] ?? 0) - (row.xs[end] ?? 0) - (row.ws[end] ?? 0);\n    const l = Math.max(0, need - gapL);\n    const r = Math.max(0, need - gapR);\n    return { l, r, c: (r - l) / 2 };\n  };\n  const roomA = room(L?.a, rowA, iZ);\n  const roomB = room(L?.b, rowB, jZ);\n  const roomP =\n    interpolate(frame, [ROOM, ROOM + ROOM_LEN], [0, 1], {\n      ...clamp,\n      easing: EASE_IN_OUT,\n    }) *\n    (1 -\n      interpolate(frame, [CLOSE, CLOSE + CLOSE_LEN], [0, 1], {\n        ...clamp,\n        easing: EASE_IN_OUT,\n      }));\n  const lerpRoom = (pick: (r: { l: number; r: number; c: number }) => number) =>\n    roomP * interpolate(reflow, [0, 1], [pick(roomA), pick(roomB)]);\n  const preShift = lerpRoom((r) => -r.l - r.c);\n  const postShift = lerpRoom((r) => r.r - r.c);\n  const midShiftA = -roomA.c * roomP;\n  const midShiftB = -roomB.c * roomP;\n\n  // The slip around the cut-out, from cap height to just under the baseline.\n  const slipA: Rect = {\n    x: ax(iA) - padX + midShiftA,\n    y: baseY - em * 0.98,\n    w: ax(iZ) + (L?.a.ws[iZ] ?? 0) - ax(iA) + padX * 2,\n    h: em * 1.26,\n  };\n  const slotB: Rect = {\n    x: bx(iA) - padX + midShiftB,\n    y: slipA.y,\n    w: bx(jZ) + (L?.b.ws[jZ] ?? 0) - bx(iA) + padX * 2,\n    h: slipA.h,\n  };\n  const P = 2 * (slipA.w + slipA.h);\n\n  // Dashed cut line, drawn clockwise.\n  const dashLen = em * 0.09;\n  const dashGap = em * 0.075;\n  const drawP = interpolate(frame, [DASH, DASH + DASH_LEN], [0, 1], {\n    ...clamp,\n    easing: EASE_OUT,\n  });\n  const cutP = interpolate(frame, [CUT, CUT + CUT_LEN], [0, 1], {\n    ...clamp,\n    easing: EASE_CUT,\n  });\n  const cutS = cutP * P;\n  const dashes: { pts: string; k: number }[] = [];\n  for (let s = 0, k = 0; s < P; s += dashLen + dashGap, k++) {\n    if (s / P > drawP) break;\n    if (s + dashLen < cutS) continue;\n    dashes.push({\n      pts: span(slipA, Math.max(s, cutS), Math.min(P, s + dashLen)),\n      k,\n    });\n  }\n\n  // Scissors: fly in to the top-left corner, cut once round, pull away.\n  const turn = em * 0.5;\n  const inP = interpolate(frame, [SC_IN, SC_IN + SC_IN_LEN], [0, 1], {\n    ...clamp,\n    easing: EASE_OUT,\n  });\n  const outP = interpolate(frame, [SC_OUT, SC_OUT + SC_OUT_LEN], [0, 1], {\n    ...clamp,\n    easing: EASE_IN,\n  });\n  const tip = along(slipA, cutS);\n  let angle = heading(slipA, cutS, turn);\n  let tipX = tip.x;\n  let tipY = tip.y;\n  if (frame < CUT) {\n    tipX += -em * 2.4 * (1 - inP);\n    tipY += -em * 0.9 * (1 - inP);\n    angle = -24 * (1 - inP);\n  }\n  // On the way out the scissors carry on along the top edge and lift away.\n  tipX += em * 1.8 * outP;\n  tipY -= em * 1.3 * outP;\n  angle -= 28 * outP;\n  const S = SCISSORS * em;\n  const pivotX = tipX - Math.cos((angle * Math.PI) / 180) * S * 0.22;\n  const pivotY = tipY - Math.sin((angle * Math.PI) / 180) * S * 0.22;\n  const travel = frame < CUT ? 0 : cutS;\n  const snipping = frame >= CUT && frame <= CUT + CUT_LEN;\n  const open = snipping\n    ? 5 + 13 * (0.5 + 0.5 * Math.cos((2 * Math.PI * travel) / (SNIP * em)))\n    : interpolate(frame, [SC_IN, SC_IN + 14], [3, 18], clamp) * (1 - outP) +\n      3 * outP;\n  const scOpacity =\n    interpolate(frame, [SC_IN, SC_IN + 8], [0, 1], clamp) *\n    (1 - interpolate(outP, [0.4, 1], [0, 1], clamp));\n\n  // The cut slip lifts, then falls away and dissolves.\n  const lift = interpolate(frame, [LIFT, LIFT + LIFT_LEN], [0, 1], {\n    ...clamp,\n    easing: EASE_OUT,\n  });\n  const fall = interpolate(\n    frame,\n    [LIFT + LIFT_LEN - 2, LIFT + LIFT_LEN - 2 + FALL_LEN],\n    [0, 1],\n    { ...clamp, easing: EASE_IN },\n  );\n  const slipDX = em * 0.35 * fall;\n  const slipDY = -em * 0.1 * lift + em * 2.4 * fall;\n  const slipRot = -1.5 * lift + 11 * fall;\n  const slipOpacity = 1 - interpolate(fall, [0.35, 1], [0, 1], clamp);\n  const slipBlur = interpolate(fall, [0.3, 1], [0, 9], clamp);\n\n  // The slot the slip leaves: closes up to the new word, then fades.\n  const slot: Rect = {\n    x: interpolate(reflow, [0, 1], [slipA.x, slotB.x]),\n    y: slipA.y,\n    w: interpolate(reflow, [0, 1], [slipA.w, slotB.w]),\n    h: slipA.h,\n  };\n  const slotOpacity =\n    interpolate(frame, [LIFT, LIFT + 4], [0, 1], clamp) *\n    (1 - interpolate(frame, [REPL + 6, REPL + REPL_LEN], [0, 1], clamp));\n  const hair = Math.max(1.5, width / 720);\n  const fadeIn = (i: number) =>\n    interpolate(\n      frame,\n      [IN + i * IN_STAGGER, IN + i * IN_STAGGER + 18],\n      [0, 1],\n      {\n        ...clamp,\n        easing: EASE_OUT,\n      },\n    );\n  const glyph = (\n    ch: string,\n    key: string,\n    x: number,\n    p: number,\n    color: string,\n    dy = 0,\n  ) =>\n    ch === \" \" ? null : (\n      <span\n        key={key}\n        style={{\n          ...textStyle,\n          position: \"absolute\",\n          left: x,\n          top: rowTop,\n          color,\n          opacity: p,\n          filter: p < 1 ? `blur(${(1 - p) * 8}px)` : undefined,\n          transform: `translateY(${(1 - p) * em * 0.14 + dy}px)`,\n        }}\n      >\n        {ch}\n      </span>\n    );\n\n  const rectPath = (r: Rect) =>\n    `M ${r.x + r.w / 2} ${r.y} H ${r.x + r.w} V ${r.y + r.h} H ${r.x} V ${r.y} Z`;\n  const slipCx = slipA.x + slipA.w / 2;\n  const slipCy = slipA.y + slipA.h / 2;\n\n  return (\n    <AbsoluteFill style={{ background: t.bg, overflow: \"hidden\" }}>\n      {/* measuring rows: never shown */}\n      <div style={{ position: \"absolute\", visibility: \"hidden\", top: 0 }}>\n        <div ref={aRef} style={{ ...textStyle, display: \"inline-block\" }}>\n          {rowA.map((ch, i) => (\n            <span key={i} data-l=\"\">\n              {ch}\n            </span>\n          ))}\n          <span\n            ref={baseRef}\n            style={{ display: \"inline-block\", width: 0, height: 0 }}\n          />\n        </div>\n        <br />\n        <div ref={bRef} style={{ ...textStyle, display: \"inline-block\" }}>\n          {rowB.map((ch, i) => (\n            <span key={i} data-l=\"\">\n              {ch}\n            </span>\n          ))}\n        </div>\n      </div>\n\n      <div style={{ position: \"absolute\", inset: 0, opacity: L ? 1 : 0 }}>\n        {/* the slot left behind */}\n        <svg\n          width={width}\n          height={height}\n          style={{ position: \"absolute\", inset: 0 }}\n          aria-hidden\n        >\n          <path\n            d={rectPath(slot)}\n            fill=\"none\"\n            stroke={t.edge}\n            strokeWidth={hair}\n            strokeDasharray={`${dashLen} ${dashGap}`}\n            opacity={slotOpacity * 0.7}\n          />\n        </svg>\n\n        {pre.map((ch, i) =>\n          glyph(\n            ch,\n            `p${i}`,\n            interpolate(reflow, [0, 1], [ax(i), bx(i)]) + preShift,\n            fadeIn(i),\n            t.fg,\n          ),\n        )}\n        {post.map((ch, i) =>\n          glyph(\n            ch,\n            `q${i}`,\n            interpolate(\n              reflow,\n              [0, 1],\n              [\n                ax(pre.length + mid.length + i),\n                bx(pre.length + repl.length + i),\n              ],\n            ) + postShift,\n            fadeIn(pre.length + mid.length + i),\n            t.fg,\n          ),\n        )}\n        {repl.map((ch, i) => {\n          const p = interpolate(\n            frame,\n            [REPL + i * 2, REPL + i * 2 + REPL_LEN],\n            [0, 1],\n            { ...clamp, easing: EASE_OUT },\n          );\n          return glyph(\n            ch,\n            `r${i}`,\n            bx(pre.length + i) + midShiftB,\n            p,\n            accent,\n            (1 - p) * em * 0.3,\n          );\n        })}\n\n        {/* the slip: cut-out words plus the edge cut so far */}\n        <div\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            opacity: slipOpacity,\n            filter: slipBlur > 0.05 ? `blur(${slipBlur}px)` : undefined,\n            transformOrigin: `${slipCx}px ${slipCy}px`,\n            transform: `translate(${slipDX}px, ${slipDY}px) rotate(${slipRot}deg)`,\n          }}\n        >\n          <svg\n            width={width}\n            height={height}\n            style={{ position: \"absolute\", inset: 0 }}\n            aria-hidden\n          >\n            <path\n              d={rectPath(slipA)}\n              pathLength={1}\n              fill=\"none\"\n              stroke={t.edge}\n              strokeWidth={hair}\n              strokeDasharray={`${cutP} 2`}\n            />\n          </svg>\n          {mid.map((ch, i) =>\n            glyph(ch, `m${i}`, ax(iA + i) + midShiftA, fadeIn(iA + i), t.fg),\n          )}\n        </div>\n\n        <svg\n          width={width}\n          height={height}\n          style={{ position: \"absolute\", inset: 0, overflow: \"visible\" }}\n          aria-hidden\n        >\n          {dashes.map((d) => (\n            <polyline\n              key={d.k}\n              points={d.pts}\n              fill=\"none\"\n              stroke={t.dash}\n              strokeWidth={hair}\n            />\n          ))}\n          <g\n            opacity={scOpacity}\n            transform={`translate(${pivotX} ${pivotY}) rotate(${angle}) scale(${S})`}\n          >\n            <Scissors open={open} blade={t.blade} ring={accent} bg={t.bg} />\n          </g>\n        </svg>\n      </div>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/scissors-cut.tsx"
    }
  ],
  "type": "registry:component"
}
