{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-swap",
  "title": "Text Swap",
  "description": "Replaces one line of text with another using exit-then-enter scheduling, with five transition presets: fade-through, crossfade, shared-axis-y, shared-axis-z, and cut.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/text-swap/index.tsx",
      "content": "\"use client\";\n\nimport { Easing, interpolate, useCurrentFrame } from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme } from \"@/lib/snap-cn-ui\";\n\nexport type TextSwapTransition =\n  | \"fly-through\"\n  | \"fade-through\"\n  | \"crossfade\"\n  | \"shared-axis-y\"\n  | \"shared-axis-z\"\n  | \"cut\";\n\nexport type TextSwapUnit = \"word\" | \"block\";\n\nexport interface TextSwapTransitionDefaults {\n  unit: TextSwapUnit;\n  exitDuration: number;\n  enterDuration: number;\n}\n\n/**\n * Per-transition timing defaults. `unit` picks whether the line animates as a\n * whole block or word-by-word when the `unit` prop is omitted.\n */\nexport const TRANSITION_DEFAULTS: Record<\n  TextSwapTransition,\n  TextSwapTransitionDefaults\n> = {\n  // Always a block: the line has to rush the camera as one object. Per-word it\n  // would be five objects each blowing up about its own centre, which is not a\n  // camera move, it is a collision.\n  \"fly-through\": { unit: \"block\", exitDuration: 20, enterDuration: 16 },\n  \"fade-through\": { unit: \"block\", exitDuration: 8, enterDuration: 13 },\n  crossfade: { unit: \"word\", exitDuration: 15, enterDuration: 21 },\n  \"shared-axis-y\": { unit: \"block\", exitDuration: 10, enterDuration: 14 },\n  \"shared-axis-z\": { unit: \"block\", exitDuration: 11, enterDuration: 16 },\n  cut: { unit: \"word\", exitDuration: 8, enterDuration: 8 },\n};\n\nexport interface TextSwapMotion {\n  /** Y offset (px) the outgoing text drifts to. */\n  exitY: number;\n  /** Scale the outgoing text ends at. */\n  exitScale: number;\n  /** Blur (px) the outgoing text ends at. */\n  exitBlur: number;\n  /** Y offset (px) the incoming text starts from. */\n  enterY: number;\n  /** Scale the incoming text starts from. */\n  enterScale: number;\n  /** Blur (px) the incoming text starts from. */\n  enterBlur: number;\n\n  /**\n   * Shape the exit's scale as a **perspective rush** instead of a plain ramp —\n   * see `perspectiveScale`. Only worth it when the line is meant to pass the\n   * camera rather than merely grow.\n   */\n  exitPerspective?: boolean;\n  /**\n   * Fraction of the exit that passes before the outgoing line starts to fade.\n   * 0 (the default) fades across the whole exit. A line flying at your face does\n   * not dim on the way in — it stays solid until it is on top of you and then it\n   * is gone, so this holds the fade back until the very end.\n   */\n  exitFadeStart?: number;\n  /**\n   * Motion-blur samples for the exit. See `SHUTTER` below.\n   *\n   * Not decoration. At the end of a perspective rush the line more than doubles\n   * in size between one frame and the next; drawn sharp, that does not read as\n   * speed, it reads as strobing. Sampling the exit several times across the\n   * frame and averaging is what a shutter does, and because the motion is a\n   * scale, the samples fan out radially — which is exactly the smear in the\n   * reference, sharp at the centre of the rush and streaked at the edges.\n   */\n  exitTrail?: number;\n}\n\n/** Motion targets per transition — exit drifts away from rest, enter settles into rest. */\nexport const TRANSITION_MOTION: Record<TextSwapTransition, TextSwapMotion> = {\n  // The line does not fade out and it does not slide away: it comes at you and\n  // goes past. Measured off the reference — apparent size reached ~12x before it\n  // was gone, and it stayed solid almost the whole way there.\n  \"fly-through\": {\n    exitY: 0,\n    exitScale: 12,\n    // The shutter does the smearing; this only closes the gaps between its\n    // discrete samples at the very end of the rush, where the line grows so fast\n    // that consecutive samples land a visible distance apart. It is applied in\n    // the line's own space and the scale multiplies it, so it is nothing at the\n    // start — when it must not soften the type — and enough by the end.\n    exitBlur: 0.5,\n    exitPerspective: true,\n    exitFadeStart: 0.72,\n    exitTrail: 18,\n    // The replacement is revealed *behind* the line that just flew past, so it\n    // arrives from depth: small, out of focus, resolving into place.\n    enterY: 0,\n    enterScale: 0.82,\n    enterBlur: 9,\n  },\n  \"fade-through\": {\n    exitY: -4,\n    exitScale: 1,\n    exitBlur: 0,\n    enterY: 6,\n    enterScale: 0.99,\n    enterBlur: 2,\n  },\n  crossfade: {\n    exitY: -6,\n    exitScale: 1,\n    exitBlur: 0,\n    enterY: 8,\n    enterScale: 1,\n    enterBlur: 0,\n  },\n  \"shared-axis-y\": {\n    exitY: -24,\n    exitScale: 1,\n    exitBlur: 0,\n    enterY: 24,\n    enterScale: 1,\n    enterBlur: 0,\n  },\n  \"shared-axis-z\": {\n    exitY: 0,\n    exitScale: 1.06,\n    exitBlur: 1,\n    enterY: 0,\n    enterScale: 0.9,\n    enterBlur: 2,\n  },\n  cut: {\n    exitY: 0,\n    exitScale: 1,\n    exitBlur: 0,\n    enterY: 0,\n    enterScale: 1,\n    enterBlur: 0,\n  },\n};\n\nconst TRANSITION_EASINGS: Record<\n  TextSwapTransition,\n  { exit: (t: number) => number; enter: (t: number) => number }\n> = {\n  \"fly-through\": {\n    // This is the *travel*, not the scale — how far the line has moved toward\n    // the eye. Fitted to the reference: near-constant speed with a little\n    // acceleration off the mark. All the drama is the perspective, not the\n    // curve; a bezier cannot make something blow up eightfold in three frames,\n    // and it should not have to.\n    exit: Easing.bezier(1, 0.65, 0.85, 1),\n    // A moderate decelerate. Not an expo/quint-out: over a 16-frame settle those\n    // spend most of their frames moving less than a pixel, which renders as\n    // identical frames — the text visibly stops dead and waits.\n    enter: Easing.bezier(0.2, 0.6, 0.35, 1),\n  },\n  \"fade-through\": {\n    exit: Easing.bezier(0.4, 0, 1, 1),\n    enter: Easing.bezier(0.2, 0, 0, 1),\n  },\n  crossfade: {\n    exit: Easing.bezier(0.7, 0, 0.84, 0),\n    enter: Easing.bezier(0.16, 1, 0.3, 1),\n  },\n  \"shared-axis-y\": {\n    exit: Easing.bezier(0.4, 0, 1, 1),\n    enter: Easing.bezier(0.2, 0, 0, 1),\n  },\n  \"shared-axis-z\": {\n    exit: Easing.bezier(0.4, 0, 1, 1),\n    enter: Easing.bezier(0.2, 0, 0, 1),\n  },\n  cut: { exit: Easing.step1, enter: Easing.step1 },\n};\n\n/**\n * Apparent size of something travelling toward the camera.\n *\n * `travel` is 0 at rest and 1 at the moment it reaches the eye; `maxScale` is\n * how big it gets before it is gone. Size goes as `1 / (1 - travel)`, so it\n * creeps for most of the trip and then blows up right at the end — the whole\n * character of a thing rushing past you.\n *\n * This is why the exit is not a plain `interpolate(…, [1, exitScale])`. Fitted\n * against the reference, that shape is off by rmse 0.31 and *cannot* reach the\n * blowup at all; this one lands at 10.09 where the reference measured 10.28.\n * The easing then describes the travel, which is nearly linear — the drama is\n * perspective, not easing, and asking a bezier to fake it never works.\n */\nexport function perspectiveScale(travel: number, maxScale: number): number {\n  if (maxScale <= 1) return 1;\n  const p = 1 - 1 / maxScale;\n  // Clamp short of 1: at travel = 1 / p the denominator is 0 and the line is\n  // already long gone.\n  return 1 / (1 - p * Math.min(Math.max(travel, 0), 0.9999));\n}\n\n/**\n * How long the shutter is open, in frames, when `exitTrail` is on. One whole\n * frame — a 360° shutter. Anything shorter leaves gaps between the samples at\n * the speeds this transition reaches.\n */\nconst SHUTTER = 1;\n\n/** Split a line into animatable segments for the given unit. */\nexport function splitSegments(text: string, unit: TextSwapUnit): string[] {\n  return unit === \"word\" ? text.split(\" \") : [text];\n}\n\n/**\n * Exit-then-enter scheduling: the incoming text starts once the outgoing text\n * (including its stagger tail) is almost gone, minus `overlap`, plus a\n * `microDelay` beat so the swap reads as two distinct moments.\n */\nexport function getEnterStart(opts: {\n  exitDuration: number;\n  segmentCount: number;\n  exitStagger: number;\n  overlap: number;\n  microDelay: number;\n}): number {\n  const exitTotal =\n    opts.exitDuration + Math.max(0, opts.segmentCount - 1) * opts.exitStagger;\n  return Math.max(0, exitTotal - opts.overlap + opts.microDelay);\n}\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nexport interface TextSwapProps {\n  fromText: string;\n  toText: string;\n  /** Animate word-by-word or as one block. Defaults per transition. */\n  unit?: TextSwapUnit;\n  transition?: TextSwapTransition;\n  /** Frames each outgoing segment takes to exit. Defaults per transition. */\n  exitDuration?: number;\n  /** Frames each incoming segment takes to enter. Defaults per transition. */\n  enterDuration?: number;\n  exitStagger?: number;\n  enterStagger?: number;\n  overlap?: number;\n  microDelay?: number;\n  fontSize?: number;\n  /** Overrides the design system's `foreground`. */\n  color?: string;\n  fontWeight?: number;\n  speed?: number;\n  className?: string;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n}\n\nexport function TextSwap({\n  fromText,\n  toText,\n  unit,\n  transition = \"fly-through\",\n  exitDuration,\n  enterDuration,\n  exitStagger = 1,\n  enterStagger = 2,\n  overlap = 1,\n  microDelay = 2,\n  fontSize = 72,\n  color,\n  fontWeight = 600,\n  speed = 1,\n  className,\n  theme,\n  mode,\n}: TextSwapProps) {\n  const frame = useCurrentFrame() * speed;\n  const t = useSnapCnTheme(theme, mode);\n  const fill = color ?? t.foreground;\n\n  const defaults = TRANSITION_DEFAULTS[transition];\n  const motion = TRANSITION_MOTION[transition];\n  const easing = TRANSITION_EASINGS[transition];\n\n  const resolvedUnit = unit ?? defaults.unit;\n  const exitDur = exitDuration ?? defaults.exitDuration;\n  const enterDur = enterDuration ?? defaults.enterDuration;\n\n  const fromSegments = splitSegments(fromText, resolvedUnit);\n  const toSegments = splitSegments(toText, resolvedUnit);\n\n  const enterStart = getEnterStart({\n    exitDuration: exitDur,\n    segmentCount: fromSegments.length,\n    exitStagger,\n    overlap,\n    microDelay,\n  });\n\n  const isWord = resolvedUnit === \"word\";\n  const exitFadeStart = motion.exitFadeStart ?? 0;\n\n  // The frames the shutter sees. One sample (the default) is just the exit drawn\n  // sharp, and costs nothing — the copies only exist for transitions that move\n  // fast enough to strobe without them.\n  const trail = Math.max(1, Math.round(motion.exitTrail ?? 1));\n  const exitSamples = Array.from(\n    { length: trail },\n    (_, sample) => frame - (sample / trail) * SHUTTER,\n  );\n\n  const fontStack =\n    \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\";\n  const lineStyle: React.CSSProperties = {\n    fontSize,\n    fontWeight,\n    color: fill,\n    letterSpacing: \"-0.02em\",\n    fontFamily: fontStack,\n    // Hinting bends each glyph's outline so its stems land on whole pixels. As\n    // the size slides, every stem re-snaps to a different grid and the\n    // letterforms change shape from frame to frame — they boil. Off, the type\n    // reads a shade softer, which is not blur, it is the absence of a lie.\n    textRendering: \"geometricPrecision\",\n  };\n  const layerStyle: React.CSSProperties = {\n    position: \"absolute\",\n    inset: 0,\n    display: \"flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  };\n\n  return (\n    <div\n      className={className}\n      style={{ position: \"absolute\", inset: 0, background: \"transparent\" }}\n    >\n      {/*\n        The shutter. Every sample gets an equal 1/n slice of it and they are\n        blended with `plus-lighter`, which *adds* premultiplied colour and alpha\n        rather than painting one over the next. A pixel the line covered for half\n        the shutter therefore ends up half opaque, which is precisely what a\n        shutter does and precisely what makes it read as speed.\n\n        Ordinary `source-over` cannot do this, and getting it wrong is worth\n        spelling out because the result is so plausible: stacking the samples at\n        1/1, 1/2, 1/3 … averages *opaque* layers correctly, but these layers are\n        type on transparent, so the first sample stays fully solid wherever the\n        others miss it, and every overlap drives alpha toward 1. What you get is a\n        sharp, over-dark line with ghosts hung around it and visibly fattened\n        stems — not a smear. `isolation` keeps the additive blending inside this\n        group instead of leaking onto whatever the scene is sitting on.\n      */}\n      <div style={{ ...layerStyle, isolation: \"isolate\" }}>\n        {exitSamples.map((sampleFrame, sample) => (\n          <div\n            // biome-ignore lint/suspicious/noArrayIndexKey: shutter samples are positional\n            key={sample}\n            style={{\n              ...layerStyle,\n              opacity: 1 / exitSamples.length,\n              mixBlendMode: \"plus-lighter\",\n            }}\n          >\n            <span style={lineStyle}>\n              {fromSegments.map((segment, i) => {\n                const local = sampleFrame - i * exitStagger;\n                // 0 at rest, 1 at the eye. The easing shapes the *travel*; the\n                // perspective (or the plain ramp) turns that into apparent size.\n                const travel = interpolate(local, [0, exitDur], [0, 1], {\n                  ...CLAMP,\n                  easing: easing.exit,\n                });\n                return (\n                  <span\n                    // biome-ignore lint/suspicious/noArrayIndexKey: segments are positional and never reorder\n                    key={`${segment}-${i}`}\n                    style={{\n                      display: \"inline-block\",\n                      marginRight: isWord ? \"0.25em\" : undefined,\n                      transformOrigin: \"50% 50%\",\n                      opacity: interpolate(\n                        local,\n                        [exitFadeStart * exitDur, exitDur],\n                        [1, 0],\n                        { ...CLAMP, easing: easing.exit },\n                      ),\n                      translate: `0 ${motion.exitY * travel}px`,\n                      scale: `${\n                        motion.exitPerspective\n                          ? perspectiveScale(travel, motion.exitScale)\n                          : 1 + (motion.exitScale - 1) * travel\n                      }`,\n                      filter: `blur(${motion.exitBlur * travel}px)`,\n                    }}\n                  >\n                    {segment}\n                  </span>\n                );\n              })}\n            </span>\n          </div>\n        ))}\n      </div>\n\n      <div style={layerStyle}>\n        <span style={lineStyle}>\n          {toSegments.map((segment, j) => {\n            const local = frame - enterStart - j * enterStagger;\n            return (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: segments are positional and never reorder\n                key={`${segment}-${j}`}\n                style={{\n                  display: \"inline-block\",\n                  marginRight: isWord ? \"0.25em\" : undefined,\n                  transformOrigin: \"50% 50%\",\n                  opacity: interpolate(local, [0, enterDur], [0, 1], {\n                    ...CLAMP,\n                    easing: easing.enter,\n                  }),\n                  translate: `0 ${interpolate(\n                    local,\n                    [0, enterDur],\n                    [motion.enterY, 0],\n                    { ...CLAMP, easing: easing.enter },\n                  )}px`,\n                  scale: `${interpolate(\n                    local,\n                    [0, enterDur],\n                    [motion.enterScale, 1],\n                    { ...CLAMP, easing: easing.enter },\n                  )}`,\n                  filter: `blur(${interpolate(\n                    local,\n                    [0, enterDur],\n                    [motion.enterBlur, 0],\n                    { ...CLAMP, easing: easing.enter },\n                  )}px)`,\n                }}\n              >\n                {segment}\n              </span>\n            );\n          })}\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/text-swap.tsx"
    }
  ],
  "type": "registry:component"
}