{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caret-swap",
  "title": "Caret Swap",
  "description": "A gradient caret swells into a block that consumes the first phrase right-to-left, collapses back and types the second phrase out with a blinking caret.",
  "dependencies": ["remotion"],
  "files": [
    {
      "path": "registry/remocn/caret-swap/index.tsx",
      "content": "\"use client\";\n\nimport { Easing, interpolate, useCurrentFrame } from \"remotion\";\n\nexport interface CaretSwapProps {\n  fromText: string;\n  toText: string;\n  fontSize?: number;\n  color?: string;\n  fontWeight?: number;\n  caretColor?: string;\n  caretColorEnd?: string;\n  swapAt?: number;\n  typeSpeed?: number;\n  speed?: number;\n  className?: string;\n}\n\nconst CARET_LEAD = 7;\nconst EXPAND_FRAMES = 6;\nconst COLLAPSE_FRAMES = 7;\nconst IDLE_FRAMES = 7;\nconst BLINK_FRAMES = 8;\nconst CARET_WIDTH_EM = 0.08;\nconst CARET_HEIGHT_EM = 1.1;\nconst CARET_GAP_EM = 0.06;\nconst CHAR_WIDTH_EM = 0.52;\nconst TYPE_ACCEL = 0.96;\n\nfunction typeElapsed(count: number, typeSpeed: number): number {\n  return (typeSpeed * (1 - TYPE_ACCEL ** count)) / (1 - TYPE_ACCEL);\n}\n\nexport function caretSwapLength(\n  toText: string,\n  swapAt = 20,\n  typeSpeed = 1,\n): number {\n  return Math.ceil(\n    swapAt +\n      EXPAND_FRAMES +\n      COLLAPSE_FRAMES +\n      IDLE_FRAMES +\n      typeElapsed(Array.from(toText).length, typeSpeed),\n  );\n}\n\nconst expandEasing = Easing.bezier(0.6, 0, 0.8, 0.4);\nconst collapseEasing = Easing.bezier(0.2, 0.7, 0.3, 1);\n\nexport function CaretSwap({\n  fromText,\n  toText,\n  fontSize = 72,\n  color = \"#171717\",\n  fontWeight = 400,\n  caretColor = \"#FF4DAA\",\n  caretColorEnd = \"#8A5CF6\",\n  swapAt = 20,\n  typeSpeed = 1,\n  speed = 1,\n  className,\n}: CaretSwapProps) {\n  const frame = useCurrentFrame() * speed;\n\n  const toChars = Array.from(toText);\n  const caretWidth = CARET_WIDTH_EM * fontSize;\n  const caretHeight = CARET_HEIGHT_EM * fontSize;\n\n  const caretVisibleAt = Math.max(swapAt - CARET_LEAD, 0);\n  const expandEnd = swapAt + EXPAND_FRAMES;\n  const collapseEnd = expandEnd + COLLAPSE_FRAMES;\n  const typeStart = collapseEnd + IDLE_FRAMES;\n  const typeEnd = typeStart + typeElapsed(toChars.length, typeSpeed);\n\n  const expand = interpolate(frame, [swapAt, expandEnd], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: expandEasing,\n  });\n  const collapse = interpolate(frame, [expandEnd, collapseEnd], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: collapseEasing,\n  });\n\n  const typing = frame >= typeStart;\n  let typedCount = 0;\n  if (typing) {\n    while (\n      typedCount < toChars.length &&\n      typeElapsed(typedCount, typeSpeed) <= frame - typeStart\n    ) {\n      typedCount++;\n    }\n  }\n  const typed = toChars.slice(0, typedCount).join(\"\");\n\n  const anchorWidthEm =\n    Array.from(fromText).length * CHAR_WIDTH_EM + CARET_WIDTH_EM + CARET_GAP_EM;\n  const targetShiftEm = (anchorWidthEm - toChars.length * CHAR_WIDTH_EM) / 2;\n  const typeProgress = toChars.length > 0 ? typedCount / toChars.length : 0;\n  const typedShift = targetShiftEm * typeProgress * fontSize;\n\n  const blinkOff =\n    frame >= typeEnd + BLINK_FRAMES &&\n    Math.floor((frame - typeEnd) / BLINK_FRAMES) % 2 === 1;\n\n  const caretStyle = {\n    background: `linear-gradient(180deg, ${caretColor}, ${caretColorEnd})`,\n    borderRadius: fontSize * 0.03,\n  };\n\n  const overlayWidth =\n    frame < expandEnd\n      ? `calc(${(expand * 100).toFixed(3)}% + ${((1 - expand) * caretWidth).toFixed(2)}px)`\n      : `calc(${((1 - collapse) * 100).toFixed(3)}% + ${(collapse * caretWidth).toFixed(2)}px)`;\n\n  const tailSolidStop =\n    frame < expandEnd ? 100 - 90 * expand : 10 + 90 * collapse;\n  const tailMask =\n    frame >= swapAt\n      ? `linear-gradient(90deg, black ${tailSolidStop.toFixed(1)}%, transparent 100%)`\n      : undefined;\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        whiteSpace: \"nowrap\",\n        background: \"transparent\",\n      }}\n    >\n      <span\n        className={className}\n        style={{\n          position: \"relative\",\n          display: \"inline-flex\",\n          alignItems: \"center\",\n          fontSize,\n          fontWeight,\n          color,\n          letterSpacing: \"-0.02em\",\n          lineHeight: 1.15,\n          fontFamily:\n            \"var(--font-geist-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif)\",\n        }}\n      >\n        <span\n          style={{\n            display: \"inline-block\",\n            whiteSpace: \"pre\",\n            visibility: frame < expandEnd ? \"visible\" : \"hidden\",\n          }}\n        >\n          {fromText}\n        </span>\n        <span\n          aria-hidden\n          style={{\n            ...caretStyle,\n            display: \"inline-block\",\n            flexShrink: 0,\n            width: caretWidth,\n            height: caretHeight,\n            marginLeft: CARET_GAP_EM * fontSize,\n            visibility: \"hidden\",\n          }}\n        />\n        {!typing && (\n          <span\n            aria-hidden\n            style={{\n              ...caretStyle,\n              position: \"absolute\",\n              top: \"50%\",\n              transform: \"translateY(-50%)\",\n              height: caretHeight,\n              ...(frame < swapAt\n                ? { right: 0, width: caretWidth }\n                : frame < expandEnd\n                  ? { right: 0, width: overlayWidth }\n                  : { left: 0, width: overlayWidth }),\n              WebkitMaskImage: tailMask,\n              maskImage: tailMask,\n              opacity: frame >= caretVisibleAt ? 1 : 0,\n            }}\n          />\n        )}\n        {typing && (\n          <span\n            style={{\n              position: \"absolute\",\n              left: 0,\n              top: \"50%\",\n              transform: `translate(${typedShift.toFixed(1)}px, -50%)`,\n              display: \"inline-flex\",\n              alignItems: \"center\",\n              whiteSpace: \"pre\",\n            }}\n          >\n            <span style={{ display: \"inline-block\", whiteSpace: \"pre\" }}>\n              {typed}\n            </span>\n            <span\n              aria-hidden\n              style={{\n                ...caretStyle,\n                display: \"inline-block\",\n                flexShrink: 0,\n                width: caretWidth,\n                height: caretHeight,\n                marginLeft: CARET_GAP_EM * fontSize,\n                opacity: blinkOff ? 0 : 1,\n              }}\n            />\n          </span>\n        )}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/caret-swap.tsx"
    }
  ],
  "type": "registry:component"
}
