{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "word-flip",
  "title": "Word Flip",
  "description": "A headline types itself out, then one word cycles on a 3D flip — it sinks into an anticipation dip, throws up and away about its baseline under motion blur, and the next word unfurls from below. The slot is reserved from the first keystroke, so nothing in the sentence ever reflows.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/word-flip/index.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport { mixOklch, type SnapCnTheme, useSnapCnTheme } from \"@/lib/snap-cn-ui\";\n\n/**\n * Pure animation math for WordFlip. Everything above the component is\n * frame-deterministic and side-effect free so it can be unit tested.\n */\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\n/** Frames each character takes, from a characters-per-second rate. */\nexport function framesPerChar(cps: number, fps: number): number {\n  return fps / Math.max(0.001, cps);\n}\n\n/**\n * A deterministic, seedless wobble on the keystroke clock so the typing does not\n * land on a metronome. Real typing is uneven; a perfectly regular cadence is the\n * single thing that gives a \"typewriter\" away. Bounded to ±`jitter` of one\n * keystroke and summed into an *offset*, never a per-key delay, so the sentence\n * still finishes exactly when the timeline says it does.\n */\nexport function keystrokeOffset(index: number, jitter: number): number {\n  if (jitter <= 0) return 0;\n  // Two incommensurable sines: no repeat over any realistic sentence, and no RNG,\n  // so a render and the Player agree frame for frame.\n  const w = Math.sin(index * 12.9898) * 0.6 + Math.sin(index * 4.1414) * 0.4;\n  return w * jitter;\n}\n\n/** Frame at which character `index` starts to appear. */\nexport function charStartFrame(\n  index: number,\n  opts: { typeStart: number; cps: number; fps: number; jitter: number },\n): number {\n  const per = framesPerChar(opts.cps, opts.fps);\n  return (\n    opts.typeStart + index * per + keystrokeOffset(index, opts.jitter) * per\n  );\n}\n\n/** Frame the last character has finished fading in. */\nexport function typingEndFrame(opts: {\n  charCount: number;\n  typeStart: number;\n  cps: number;\n  fps: number;\n  jitter: number;\n  charFade: number;\n}): number {\n  const last = charStartFrame(Math.max(0, opts.charCount - 1), opts);\n  return last + opts.charFade;\n}\n\n/** Frame at which flip `i` (0-based) begins. */\nexport function flipStartFrame(\n  i: number,\n  opts: { typingEnd: number; pause: number; cycle: number },\n): number {\n  return opts.typingEnd + opts.pause + i * opts.cycle;\n}\n\n/**\n * Which word is showing at `frame`, and how far into its flip we are.\n *\n * `index` is the word entering (or resting); `local` is frames since that\n * flip began, and is negative while the previous word is still holding.\n */\nexport function wordAt(\n  frame: number,\n  opts: {\n    typingEnd: number;\n    pause: number;\n    cycle: number;\n    wordCount: number;\n    loop: boolean;\n  },\n): { index: number; local: number } {\n  const first = flipStartFrame(0, opts);\n  if (frame < first) return { index: 0, local: frame - first };\n  const n = Math.floor((frame - first) / opts.cycle);\n  const local = frame - first - n * opts.cycle;\n  // Flip n takes word n -> word n+1. Before the first flip the slot is empty, so\n  // flip 0 brings in word 0 and flip k brings in word k.\n  const raw = n;\n  const index = opts.loop\n    ? ((raw % opts.wordCount) + opts.wordCount) % opts.wordCount\n    : Math.min(raw, opts.wordCount - 1);\n  return { index, local };\n}\n\n/**\n * The anticipation curve — `easeInBack`.\n *\n * The reference does not simply throw the word upward: it sinks it first. That\n * backswing is the whole reason the flip reads as weight rather than as a cut,\n * and it is not a separate keyframe — one `easeInBack` progress drives the\n * translate and the rotation together, so the dip and the throw are the same\n * gesture.\n *\n * Its minimum is `p = -0.100` at `t = 2s/(3(s+1)) = 0.42`. Measured off the\n * reference: the dip bottoms out at t = 0.44 of the exit and is worth 0.100 of\n * the exit travel. That is the curve, not an approximation of it.\n */\nexport function easeInBack(t: number, s = 1.70158): number {\n  return t * t * ((s + 1) * t - s);\n}\n\n/**\n * Speed of {@link easeInBack}, normalised so the fastest instant is 1.\n *\n * The blur is a shutter, not a ramp: it has to be proportional to how fast the\n * word is actually travelling. That is not a stylistic preference, it is what\n * the reference does — at the bottom of the dip the word has *stopped* to turn\n * around, and it is sharp there; three frames later it is flying and it is\n * smeared. A blur keyed to progress instead of speed blurs the word while it is\n * standing still, which reads as a focus pull.\n *\n * `easeInBack` is a cubic, so this is exact rather than sampled — and its\n * derivative is zero precisely at the turnaround, which is the frame that has\n * to be sharp.\n */\nexport function easeInBackSpeed(t: number, s = 1.70158): number {\n  const d = 3 * (s + 1) * t * t - 2 * s * t;\n  const peak = 3 * (s + 1) - 2 * s; // the derivative at t = 1, its maximum\n  return Math.abs(d) / peak;\n}\n\n/**\n * Uniform scale that makes every word fill the same slot.\n *\n * The reference pins *both* edges of the slot: all three words measure the same\n * width to within 1%, while their heights differ by up to 6% — exactly inversely\n * with their aspect ratios. That identity only closes if each word is scaled to\n * a common width, and it is what makes the layout incapable of reflowing: the\n * widest word sits at scale 1 and the rest are scaled up to meet it.\n */\nexport function fitScales(widths: number[]): number[] {\n  const max = Math.max(...widths, 1);\n  return widths.map((w) => (w > 0 ? max / w : 1));\n}\n\nexport interface WordFlipMotion {\n  /** How far the outgoing word is thrown, in em. Negative is up. */\n  exitY: number;\n  /** How far below rest the incoming word starts, in em. */\n  enterY: number;\n  /** Degrees the word rotates about the baseline. */\n  rotate: number;\n  /** Scale the outgoing word shrinks to (and the incoming grows from). */\n  scale: number;\n  /** Peak blur, in em. */\n  blur: number;\n}\n\n/** Measured off the reference. See the doc page for the derivation. */\nexport const DEFAULT_MOTION: WordFlipMotion = {\n  // 0.100 x 1.29em = 0.13em of backswing, which is the ~10px dip the reference\n  // shows at a 72px font.\n  exitY: -1.29,\n  enterY: 0.135,\n  rotate: 90,\n  scale: 0.98,\n  blur: 0.085,\n};\n\nexport interface WordFlipProps {\n  /** Text before the flipping word. */\n  prefix?: string;\n  /** The words that cycle through the slot. */\n  words?: string[];\n  /** Text after the flipping word. */\n  suffix?: string;\n  /**\n   * Gradient painted on the flipping word, left to right. Defaults to the\n   * design system's accent walked toward `destructive` — a two-stop ramp that\n   * follows a user's theme instead of a fixed blue-to-pink.\n   */\n  gradient?: string[];\n  /** Typing speed, characters per second. */\n  cps?: number;\n  /** Frames before the first keystroke. */\n  typeStart?: number;\n  /** Frames each character takes to fade in. */\n  charFade?: number;\n  /** Keystroke unevenness, as a fraction of one keystroke. 0 is a metronome. */\n  jitter?: number;\n  /** Frames held after the sentence completes, before the first flip. */\n  pause?: number;\n  /** Frames from one flip to the next. */\n  cycle?: number;\n  /** Frames the outgoing word takes to leave. */\n  exitDuration?: number;\n  /** Frames the incoming word takes to settle. */\n  enterDuration?: number;\n  /** Frames the incoming word starts before the outgoing one is gone. */\n  overlap?: number;\n  /** Show a blinking caret while typing. The reference has none. */\n  caret?: boolean;\n  /** Keep cycling the words forever. */\n  loop?: boolean;\n  motion?: Partial<WordFlipMotion>;\n  /** 3D depth, in em. Smaller is a stronger perspective. */\n  perspective?: number;\n  /**\n   * The typeface. Defaults to the shadcn app's sans stack. A headline is set in\n   * *your* type, and the inline style would otherwise beat anything a className\n   * could say — so this is the prop that lets it.\n   */\n  fontFamily?: string;\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\nconst FONT_FAMILY =\n  \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\";\n\nexport function WordFlip({\n  prefix = \"Looking For A\",\n  words = [\"Modern\", \"Stunning\", \"Minimal\"],\n  suffix = \"Portfolio\",\n  gradient,\n  cps = 9,\n  typeStart = 4,\n  charFade = 6,\n  jitter = 0.18,\n  pause = 6,\n  cycle = 35,\n  exitDuration = 9,\n  enterDuration = 9,\n  overlap = 3,\n  caret = true,\n  loop = true,\n  motion,\n  perspective = 6.5,\n  fontFamily = FONT_FAMILY,\n  fontSize = 72,\n  color,\n  fontWeight = 600,\n  speed = 1,\n  className,\n  theme,\n  mode,\n}: WordFlipProps) {\n  const frame = useCurrentFrame() * speed;\n  const { fps } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const fill = color ?? t.foreground;\n  const stops = gradient ?? [\n    t.primary,\n    mixOklch(t.primary, t.destructive, 0.45),\n  ];\n\n  const m = { ...DEFAULT_MOTION, ...motion };\n\n  // ---------------------------------------------------------------------------\n  // Measure. offsetWidth/offsetTop are *layout* px — untouched by the transforms\n  // we are about to apply, which is exactly why they are the right tool here. We\n  // measure in a hidden, untransformed copy so frame 0 can never be captured\n  // against the wrong geometry.\n  // ---------------------------------------------------------------------------\n  const [metrics, setMetrics] = useState<{\n    widths: number[];\n    baseline: number;\n  } | null>(null);\n  const [handle] = useState(() => delayRender(\"word-flip: measuring the slot\"));\n  const wordRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const baselineRef = useRef<HTMLSpanElement | null>(null);\n\n  useEffect(() => {\n    if (metrics) {\n      // Only now has the measured geometry actually rendered.\n      continueRender(handle);\n      return;\n    }\n    const widths = words.map((_, i) => wordRefs.current[i]?.offsetWidth ?? 0);\n    const baseline = baselineRef.current?.offsetTop ?? 0;\n    if (widths.length > 0 && widths.every((w) => w > 0)) {\n      setMetrics({ widths, baseline });\n    }\n  }, [metrics, handle, words]);\n\n  const scales = metrics ? fitScales(metrics.widths) : words.map(() => 1);\n  const slotWidth = metrics ? Math.max(...metrics.widths) : 0;\n  const baseline = metrics?.baseline ?? 0;\n\n  // ---------------------------------------------------------------------------\n  // Timeline\n  // ---------------------------------------------------------------------------\n  const chars = [...prefix, ...suffix];\n  const clock = { typeStart, cps, fps, jitter };\n  const typingEnd = typingEndFrame({\n    charCount: chars.length,\n    charFade,\n    ...clock,\n  });\n  const schedule = { typingEnd, pause, cycle, wordCount: words.length, loop };\n  const { index: entering, local } = wordAt(frame, schedule);\n\n  const em = fontSize;\n  const outgoing = entering - 1;\n  const enterStart = Math.max(0, exitDuration - overlap);\n\n  /** The word that is leaving: one `easeInBack` progress drives everything. */\n  const exitAt = (localFrame: number) => {\n    const t = interpolate(localFrame, [0, exitDuration], [0, 1], CLAMP);\n    const p = easeInBack(t);\n    return {\n      y: p * m.exitY * em,\n      rotate: p * m.rotate,\n      scale: 1 + p * (m.scale - 1),\n      blur: easeInBackSpeed(t) * m.blur * em,\n      // Solid until it is genuinely on its way out. A word that starts dimming on\n      // the backswing reads as a crossfade; the reference holds it almost fully\n      // opaque through the throw and then it is simply gone.\n      opacity: interpolate(t, [0.65, 1], [1, 0], CLAMP),\n    };\n  };\n\n  /** The word that is arriving. A moderate decelerate — see motion-quality. */\n  const enterAt = (localFrame: number) => {\n    const p = interpolate(\n      localFrame,\n      [enterStart, enterStart + enterDuration],\n      [0, 1],\n      { ...CLAMP, easing: Easing.bezier(0.2, 0.6, 0.35, 1) },\n    );\n    return {\n      y: (1 - p) * m.enterY * em,\n      rotate: -(1 - p) * m.rotate,\n      scale: m.scale + p * (1 - m.scale),\n      blur: (1 - p) * m.blur * em,\n      opacity: interpolate(p, [0, 0.45], [0, 1], CLAMP),\n    };\n  };\n\n  const stateFor = (i: number) => {\n    if (i === entering && local >= enterStart) return enterAt(local);\n    if (i === outgoing && local >= 0 && local <= exitDuration)\n      return exitAt(local);\n    if (i === entering && local > exitDuration) return enterAt(local);\n    // Resting: the word that is showing between flips.\n    if (i === entering && local < 0)\n      return { y: 0, rotate: 0, scale: 1, blur: 0, opacity: 0 };\n    return null;\n  };\n\n  // The Player is one continuous tab and hands the transform to the compositor;\n  // a render is spread across tabs that each inherit a stale raster, so the same\n  // hint there makes the type shimmer while standing still.\n  const gpu = getRemotionEnvironment().isRendering\n    ? null\n    : ({ willChange: \"transform, opacity, filter\" } as const);\n\n  const lineStyle: React.CSSProperties = {\n    fontSize,\n    fontWeight,\n    color: fill,\n    letterSpacing: \"-0.02em\",\n    fontFamily,\n    lineHeight: 1.25,\n    whiteSpace: \"pre\",\n    // Hinting re-snaps every stem to the pixel grid as the size slides, so the\n    // letterforms change shape frame to frame. Off, the type reads a shade\n    // softer — that is the absence of a lie, not blur.\n    textRendering: \"geometricPrecision\",\n  };\n\n  const wordPaint: React.CSSProperties = {\n    backgroundImage: `linear-gradient(90deg, ${stops.join(\", \")})`,\n    WebkitBackgroundClip: \"text\",\n    backgroundClip: \"text\",\n    color: \"transparent\",\n    WebkitTextFillColor: \"transparent\",\n  };\n\n  const typedChar = (ch: string, i: number) => {\n    const start = charStartFrame(i, clock);\n    return (\n      <span\n        key={i}\n        style={{\n          opacity: interpolate(frame, [start, start + charFade], [0, 1], CLAMP),\n        }}\n      >\n        {ch}\n      </span>\n    );\n  };\n\n  // The caret rides the typing head. It is a zero-width inline-block with an\n  // absolutely-positioned bar inside, so it cannot push a single glyph sideways\n  // — the sentence's geometry has to be identical with it and without it.\n  const caretIndex = Math.max(\n    0,\n    Math.min(\n      chars.length,\n      Math.floor((frame - typeStart) / framesPerChar(cps, fps)) + 1,\n    ),\n  );\n  const caretDone = frame > typingEnd + pause;\n  const showCaret = caret && !caretDone && frame >= typeStart;\n  const blink = Math.floor((frame / fps) * 2) % 2 === 0 ? 1 : 0.15;\n\n  const Caret = () => (\n    <span\n      style={{\n        position: \"relative\",\n        display: \"inline-block\",\n        width: 0,\n        verticalAlign: \"baseline\",\n      }}\n    >\n      <span\n        style={{\n          position: \"absolute\",\n          left: 0.06 * em,\n          bottom: 0,\n          width: Math.max(2, 0.055 * em),\n          height: 0.78 * em,\n          background: fill,\n          opacity: blink,\n          borderRadius: 1,\n        }}\n      />\n    </span>\n  );\n\n  const prefixChars = chars.slice(0, prefix.length);\n  const suffixChars = chars.slice(prefix.length);\n  const caretInPrefix = caretIndex <= prefix.length;\n\n  return (\n    <AbsoluteFill\n      className={className}\n      style={{\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        background: \"transparent\",\n      }}\n    >\n      {/*\n        The measuring copy. Hidden, never transformed, laid out exactly like the\n        real line so the widths and the baseline it reports are the ones the real\n        line will use.\n      */}\n      {!metrics && (\n        <span\n          aria-hidden\n          style={{\n            ...lineStyle,\n            position: \"absolute\",\n            visibility: \"hidden\",\n            pointerEvents: \"none\",\n            left: 0,\n            top: 0,\n          }}\n        >\n          {words.map((w, i) => (\n            <span\n              // biome-ignore lint/suspicious/noArrayIndexKey: words are positional\n              key={i}\n              ref={(el) => {\n                wordRefs.current[i] = el;\n              }}\n              style={{ display: \"inline-block\" }}\n            >\n              {w}\n              {i === 0 && (\n                // A zero-sized inline-block sits ON the baseline, so its\n                // offsetTop *is* the baseline. Never derive it from line-height.\n                <span\n                  ref={baselineRef}\n                  style={{ display: \"inline-block\", width: 0, height: 0 }}\n                />\n              )}\n            </span>\n          ))}\n        </span>\n      )}\n\n      <span style={lineStyle}>\n        {prefixChars.map((ch, i) => typedChar(ch, i))}\n        {showCaret && caretInPrefix && <Caret />}{\" \"}\n        {/*\n          The slot. Fixed width from the widest word, so it is reserved before the\n          first word ever arrives — during typing it is the gap the reference\n          shows between \"A\" and \"Portfolio\" — and nothing downstream of it can\n          ever move.\n        */}\n        <span\n          style={{\n            display: \"inline-block\",\n            position: \"relative\",\n            width: slotWidth || undefined,\n            perspective: perspective * em,\n            verticalAlign: \"baseline\",\n          }}\n        >\n          {/* In-flow strut: gives the inline-block its height and its baseline. */}\n          <span aria-hidden style={{ visibility: \"hidden\" }}>\n            {words[0] ?? \"\"}\n          </span>\n\n          {words.map((w, i) => {\n            const s = stateFor(i);\n            if (!s || s.opacity <= 0) return null;\n            return (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: words are positional\n                key={i}\n                style={{\n                  ...wordPaint,\n                  ...gpu,\n                  position: \"absolute\",\n                  left: 0,\n                  top: 0,\n                  display: \"inline-block\",\n                  width: \"max-content\",\n                  // Everything pivots on the baseline. Anywhere else and the\n                  // glyph origins climb the pixel grid in whole-pixel jumps as\n                  // the scale moves the baseline, and the word sits still, jumps,\n                  // sits still.\n                  transformOrigin: `0px ${baseline}px`,\n                  transform: `translateY(${s.y}px) rotateX(${s.rotate}deg) scale(${\n                    scales[i] * s.scale\n                  })`,\n                  opacity: s.opacity,\n                  filter: s.blur > 0.01 ? `blur(${s.blur}px)` : undefined,\n                }}\n              >\n                {w}\n              </span>\n            );\n          })}\n        </span>{\" \"}\n        {suffixChars.map((ch, i) => typedChar(ch, prefix.length + i))}\n        {showCaret && !caretInPrefix && <Caret />}\n      </span>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/word-flip.tsx"
    }
  ],
  "type": "registry:component"
}