{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-reveal",
  "title": "Text Reveal",
  "description": "One staggered enter/exit text animator: fade, slide, blur, scale, mask and tracking effects over character, word, line or block units, with 14 classic looks as presets.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/text-reveal/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 {\n  defaultLightTheme,\n  type SnapCnTheme,\n  useSnapCnTheme,\n} from \"@/lib/snap-cn-ui\";\n\n/**\n * Pure animation math for TextReveal. Everything in this file is\n * frame-deterministic and side-effect free so it can be unit tested.\n */\n\nexport type TextRevealUnit = \"character\" | \"word\" | \"line\" | \"block\";\n\nexport type TextRevealEffect =\n  | \"fade\"\n  | \"slide\"\n  | \"blur\"\n  | \"scale\"\n  | \"mask\"\n  | \"tracking\";\n\nexport type TextRevealDirection = \"up\" | \"down\" | \"left\" | \"right\";\n\nexport type TextRevealEasing =\n  | \"smooth\"\n  | \"snappy\"\n  | \"overshoot\"\n  | \"linear\"\n  | \"spring\"\n  | [number, number, number, number];\n\nexport type TextRevealExit = \"none\" | \"mirror\";\n\nexport type TextRevealPreset =\n  | \"soft-blur-in\"\n  | \"per-character-rise\"\n  | \"bottom-up-letters\"\n  | \"top-down-letters\"\n  | \"spring-scale-in\"\n  | \"micro-scale-fade\"\n  | \"scale-down-fade\"\n  | \"blur-out-up\"\n  | \"focus-blur-resolve\"\n  | \"line-by-line-slide\"\n  | \"mask-reveal-up\"\n  | \"tracking-in\"\n  | \"short-slide-right\"\n  | \"staggered-fade-up\";\n\nexport interface TextRevealSettings {\n  unit: TextRevealUnit;\n  effects: TextRevealEffect[];\n  direction: TextRevealDirection;\n  distance: number;\n  blurAmount: number;\n  scaleFrom: number;\n  /** Starting letter-spacing for the `tracking` effect, in em. */\n  trackingFrom: number;\n  stagger: number;\n  enterDuration: number;\n  easing: TextRevealEasing;\n  exit: TextRevealExit;\n  exitDirection: TextRevealDirection;\n  exitDistance: number;\n  exitBlur: number;\n  exitDuration: number;\n  exitStagger: number;\n  align: \"left\" | \"center\" | \"right\";\n  fontSize: number;\n  color: string;\n  fontWeight: number | string;\n  letterSpacing: string;\n  speed: number;\n}\n\nexport const TEXT_REVEAL_DEFAULTS: TextRevealSettings = {\n  unit: \"word\",\n  effects: [\"fade\", \"slide\"],\n  direction: \"up\",\n  distance: 24,\n  blurAmount: 8,\n  scaleFrom: 0.9,\n  trackingFrom: 0.5,\n  stagger: 2,\n  enterDuration: 18,\n  easing: \"smooth\",\n  exit: \"none\",\n  exitDirection: \"up\",\n  exitDistance: 14,\n  exitBlur: 8,\n  exitDuration: 14,\n  exitStagger: 1,\n  align: \"center\",\n  fontSize: 72,\n  // A settings preset has no hook to resolve against, so it takes the token's\n  // value directly rather than a hex — the component itself still prefers the\n  // live theme, and this keeps the customizer's default in step with it.\n  color: defaultLightTheme.foreground,\n  fontWeight: 600,\n  letterSpacing: \"-0.03em\",\n  speed: 1,\n};\n\n/**\n * The 14 legacy one-off looks, expressed as motion-prop bundles.\n * A preset overrides the motion props it defines; typography props\n * (text, fontSize, color, fontWeight, align) stay caller-controlled\n * unless the bundle lists them.\n */\nexport const TEXT_REVEAL_PRESETS: Record<\n  TextRevealPreset,\n  Partial<TextRevealSettings>\n> = {\n  \"soft-blur-in\": {\n    unit: \"character\",\n    effects: [\"fade\", \"slide\", \"blur\"],\n    direction: \"up\",\n    distance: 16,\n    blurAmount: 12,\n    stagger: 1,\n    enterDuration: 27,\n    easing: \"smooth\",\n    exit: \"none\",\n    letterSpacing: \"-0.05em\",\n  },\n  \"per-character-rise\": {\n    unit: \"character\",\n    effects: [\"fade\", \"slide\"],\n    direction: \"up\",\n    distance: 32,\n    stagger: 1,\n    enterDuration: 21,\n    easing: \"snappy\",\n    exit: \"none\",\n    letterSpacing: \"-0.05em\",\n  },\n  \"bottom-up-letters\": {\n    unit: \"character\",\n    effects: [\"fade\", \"slide\"],\n    direction: \"up\",\n    distance: 46,\n    stagger: 3,\n    enterDuration: 12,\n    easing: [0.18, 1, 0.32, 1],\n    exit: \"none\",\n    letterSpacing: \"-0.05em\",\n  },\n  \"top-down-letters\": {\n    unit: \"character\",\n    effects: [\"fade\", \"slide\"],\n    direction: \"down\",\n    distance: 46,\n    stagger: 3,\n    enterDuration: 12,\n    easing: [0.18, 1, 0.32, 1],\n    exit: \"none\",\n    letterSpacing: \"-0.05em\",\n  },\n  \"spring-scale-in\": {\n    unit: \"word\",\n    effects: [\"fade\", \"scale\"],\n    scaleFrom: 0.7,\n    stagger: 3,\n    enterDuration: 11,\n    easing: \"overshoot\",\n    exit: \"none\",\n  },\n  \"micro-scale-fade\": {\n    unit: \"block\",\n    effects: [\"fade\", \"scale\"],\n    scaleFrom: 0.96,\n    enterDuration: 18,\n    easing: [0.32, 0.72, 0, 1],\n    exit: \"none\",\n  },\n  \"scale-down-fade\": {\n    unit: \"block\",\n    effects: [\"fade\", \"slide\", \"scale\"],\n    direction: \"up\",\n    distance: 8,\n    scaleFrom: 1.04,\n    enterDuration: 16,\n    easing: \"smooth\",\n    exit: \"mirror\",\n    exitDirection: \"up\",\n    exitDistance: 8,\n    exitDuration: 11,\n  },\n  \"blur-out-up\": {\n    unit: \"word\",\n    effects: [\"fade\", \"slide\", \"blur\"],\n    direction: \"up\",\n    distance: 10,\n    blurAmount: 6,\n    stagger: 1,\n    enterDuration: 17,\n    easing: \"smooth\",\n    exit: \"mirror\",\n    exitDirection: \"up\",\n    exitDistance: 14,\n    exitBlur: 8,\n    exitDuration: 14,\n    exitStagger: 1,\n  },\n  \"focus-blur-resolve\": {\n    unit: \"block\",\n    effects: [\"fade\", \"slide\", \"blur\", \"scale\"],\n    direction: \"up\",\n    distance: 14,\n    blurAmount: 14,\n    scaleFrom: 1.01,\n    enterDuration: 23,\n    easing: \"smooth\",\n    exit: \"mirror\",\n    exitDirection: \"up\",\n    exitDistance: 10,\n    exitBlur: 10,\n    exitDuration: 16,\n  },\n  \"line-by-line-slide\": {\n    unit: \"line\",\n    effects: [\"fade\", \"slide\"],\n    direction: \"right\",\n    distance: 48,\n    stagger: 4,\n    enterDuration: 27,\n    easing: \"smooth\",\n    exit: \"mirror\",\n    exitDirection: \"right\",\n    exitDistance: 48,\n    exitDuration: 18,\n    exitStagger: 2,\n    align: \"left\",\n  },\n  \"mask-reveal-up\": {\n    unit: \"line\",\n    effects: [\"slide\", \"mask\"],\n    direction: \"up\",\n    distance: 30,\n    stagger: 3,\n    enterDuration: 23,\n    easing: \"smooth\",\n    exit: \"mirror\",\n    exitDirection: \"up\",\n    exitDistance: 22,\n    exitDuration: 16,\n    exitStagger: 2,\n  },\n  \"tracking-in\": {\n    unit: \"block\",\n    effects: [\"fade\", \"blur\", \"tracking\"],\n    blurAmount: 12,\n    trackingFrom: 0.5,\n    enterDuration: 30,\n    easing: \"spring\",\n    exit: \"none\",\n  },\n  \"short-slide-right\": {\n    unit: \"word\",\n    effects: [\"fade\", \"slide\"],\n    direction: \"right\",\n    distance: 24,\n    stagger: 3,\n    enterDuration: 16,\n    easing: \"snappy\",\n    exit: \"none\",\n  },\n  \"staggered-fade-up\": {\n    unit: \"word\",\n    effects: [\"fade\", \"slide\"],\n    direction: \"up\",\n    distance: 20,\n    stagger: 4,\n    enterDuration: 12,\n    easing: \"linear\",\n    exit: \"none\",\n  },\n};\n\nconst VALID_EFFECTS: TextRevealEffect[] = [\n  \"fade\",\n  \"slide\",\n  \"blur\",\n  \"scale\",\n  \"mask\",\n  \"tracking\",\n];\n\n/** Accepts an array or a comma-separated string (\"fade,slide\"). */\nexport function normalizeEffects(\n  effects: TextRevealEffect[] | string | undefined,\n): TextRevealEffect[] {\n  if (effects === undefined) return [...TEXT_REVEAL_DEFAULTS.effects];\n  const list = Array.isArray(effects)\n    ? effects\n    : (effects.split(\",\").map((e) => e.trim()) as TextRevealEffect[]);\n  const out = list.filter((e): e is TextRevealEffect =>\n    VALID_EFFECTS.includes(e),\n  );\n  return out.length > 0 ? out : [...TEXT_REVEAL_DEFAULTS.effects];\n}\n\n/** Splits text into animation units. `block` keeps the whole string as one unit. */\nexport function splitText(text: string, unit: TextRevealUnit): string[] {\n  switch (unit) {\n    case \"character\":\n      return Array.from(text);\n    case \"word\":\n      return text.split(\" \").filter((w) => w.length > 0);\n    case \"line\":\n      return text.split(\"\\n\");\n    case \"block\":\n      return [text];\n  }\n}\n\n/**\n * Analytic damped-spring progress curve (deterministic, no simulation).\n * Starts at 0, settles at 1 with a small (~1%) high-damping overshoot.\n */\nexport function springEase(t: number): number {\n  return 1 - Math.exp(-7 * t) * Math.cos(4.5 * t);\n}\n\nexport function resolveEasing(easing: TextRevealEasing): (t: number) => number {\n  if (Array.isArray(easing)) {\n    return Easing.bezier(easing[0], easing[1], easing[2], easing[3]);\n  }\n  switch (easing) {\n    case \"smooth\":\n      return Easing.bezier(0.22, 1, 0.36, 1);\n    case \"snappy\":\n      return Easing.bezier(0.2, 0.8, 0.2, 1);\n    case \"overshoot\":\n      return Easing.bezier(0.34, 1.56, 0.64, 1);\n    case \"spring\":\n      return springEase;\n    case \"linear\":\n      return (t: number) => t;\n  }\n}\n\n/** Accelerating ease used for all mirror exits (matches the legacy exits). */\nexport const EXIT_EASING = Easing.bezier(0.64, 0, 0.78, 0);\n\n/**\n * Offset a unit starts from so it travels in `direction` during enter.\n * \"up\" rises from below, \"right\" arrives from the left, etc.\n */\nexport function enterOffset(\n  direction: TextRevealDirection,\n  distance: number,\n): { x: number; y: number } {\n  switch (direction) {\n    case \"up\":\n      return { x: 0, y: distance };\n    case \"down\":\n      return { x: 0, y: -distance };\n    case \"left\":\n      return { x: distance, y: 0 };\n    case \"right\":\n      return { x: -distance, y: 0 };\n  }\n}\n\n/** Offset a unit travels to during a mirror exit (keeps moving in `direction`). */\nexport function exitOffset(\n  direction: TextRevealDirection,\n  distance: number,\n): { x: number; y: number } {\n  const from = enterOffset(direction, distance);\n  // `+ 0` normalizes -0 to 0 for clean equality and CSS output.\n  return { x: -from.x + 0, y: -from.y + 0 };\n}\n\nexport interface ExitScheduleInput {\n  unitCount: number;\n  enterDuration: number;\n  stagger: number;\n  exitDuration: number;\n  exitStagger: number;\n  /** Total frames available (durationInFrames * speed). */\n  totalFrames: number;\n}\n\n/**\n * Auto-schedules the mirror exit: it starts as late as possible so the last\n * unit finishes exactly at the end of the composition, but never before the\n * enter animation (including its stagger tail) has completed.\n */\nexport function scheduleExit(input: ExitScheduleInput): {\n  enterEnd: number;\n  exitStart: number;\n} {\n  const tail = Math.max(0, input.unitCount - 1);\n  const enterEnd = input.enterDuration + tail * input.stagger;\n  const exitStart = Math.max(\n    enterEnd,\n    input.totalFrames - input.exitDuration - tail * input.exitStagger,\n  );\n  return { enterEnd, exitStart };\n}\n\nexport interface TextRevealPropsInput\n  extends Partial<Omit<TextRevealSettings, \"effects\" | \"exitDirection\">> {\n  text?: string;\n  effects?: TextRevealEffect[] | string;\n  exitDirection?: TextRevealDirection;\n  preset?: TextRevealPreset | \"none\";\n  className?: string;\n}\n\n/**\n * Merges defaults, caller props and the preset bundle into concrete settings.\n * Precedence: defaults < props < preset — a preset is a locked, documented\n * look; set `preset` to \"none\" (or omit it) to drive the motion props\n * yourself. `exitDirection` inherits `direction` when unset.\n */\nexport function resolveTextRevealSettings(\n  props: TextRevealPropsInput,\n): TextRevealSettings {\n  const explicit: Record<string, unknown> = {};\n  for (const [key, value] of Object.entries(props)) {\n    if (value !== undefined && key !== \"preset\") explicit[key] = value;\n  }\n  const preset =\n    props.preset && props.preset !== \"none\"\n      ? TEXT_REVEAL_PRESETS[props.preset]\n      : undefined;\n  const merged = {\n    ...TEXT_REVEAL_DEFAULTS,\n    ...explicit,\n    ...preset,\n  } as TextRevealSettings & { effects: TextRevealEffect[] | string };\n  const exitDirection = (preset?.exitDirection ??\n    props.exitDirection ??\n    preset?.direction ??\n    props.direction ??\n    TEXT_REVEAL_DEFAULTS.direction) as TextRevealDirection;\n  return {\n    ...merged,\n    effects: normalizeEffects(merged.effects),\n    exitDirection,\n  };\n}\n\nexport interface TextRevealProps {\n  /** The sentence to assemble. Its first word leads the reveal. */\n  text?: string;\n  /** Final font size in px (the size the line settles at). */\n  fontSize?: number;\n  /** Overrides the design system's `foreground`. */\n  color?: string;\n  fontWeight?: number | string;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  /** How much larger the lead word starts (2 = twice the final size). */\n  initialScale?: number;\n  /** Frames the lead word fades in over at the very start. */\n  introDuration?: number;\n  /**\n   * Frames the lead word spends big and centred, drifting toward the viewer,\n   * before it falls back.\n   */\n  holdDuration?: number;\n  /** How far toward the viewer the lead word drifts during the hold (1.06 = 6% larger). */\n  pushScale?: number;\n  /** Frames the lead word takes to fall back from its peak to its final size. */\n  recedeDuration?: number;\n  /**\n   * Frames the line takes to slide left into its resting place. Starts with the\n   * recede but runs well past it, so the line barely moves while the lead word\n   * is still falling back and only travels once it has landed.\n   */\n  assembleDuration?: number;\n  /** Frames after the recede begins before the second word pushes in. */\n  wordDelay?: number;\n  /** Frames between each trailing word pushing in. */\n  wordStagger?: number;\n  /** Frames a trailing word takes to push into its slot. */\n  wordDuration?: number;\n  /** How far right of its slot a trailing word starts, in em. */\n  wordPush?: number;\n  /**\n   * Frames a trailing word fades in over. Deliberately tiny — the word should\n   * read as _pushed_ into place, not faded in. Raise it for a softer arrival.\n   */\n  wordFade?: number;\n  /** Resting letter-spacing (CSS value, em recommended). */\n  letterSpacing?: string;\n  speed?: number;\n  className?: string;\n}\n\n/**\n * The lead word's drift toward the viewer: decelerating, so it hangs at the\n * peak for a beat before it falls back.\n */\nconst PUSH_EASE = Easing.bezier(0.25, 1, 0.5, 1);\n\n/**\n * Shared curve for the recede and the slide-left. Heavily eased in — barely 10%\n * travelled at a third of the way through — so a long `assembleDuration` keeps\n * the line practically still while the lead word falls back, then carries it\n * left and decelerates to a dead stop. No bounce.\n */\nconst ZOOM_EASE = Easing.bezier(0.5, 0, 0.05, 1);\n\n/** A trailing word's push into its slot: quick off the mark, long soft landing. */\nconst WORD_EASE = Easing.bezier(0.22, 0.8, 0.36, 1);\n\n/**\n * Cinematic \"zoom-out from the lead word\" reveal, in four beats:\n *\n * 1. The lead word fades in large and centred — close to the viewer.\n * 2. It drifts a little _closer_ still, hanging at the peak.\n * 3. It falls back to its final size, roughly in place.\n * 4. Only once it has landed does the line travel left, while the trailing words\n *    push in one by one from the right and settle.\n *\n * Beats 3 and 4 start on the same frame but run on very different clocks: the\n * recede is short, the slide is long and heavily eased in, which is what makes\n * the word read as going _back_ first and moving left second rather than doing\n * both at once. The trailing words stay invisible until `wordDelay` frames into\n * the recede — so none of them show up mid-flight while the lead word is still\n * travelling — then cut in a push-distance right of their slot and slide home.\n *\n * Only `scale`, `translate` and `opacity` animate — nothing reflows — so it\n * stays GPU-cheap with a constant baseline and no layout shift.\n */\nexport function TextReveal({\n  text = \"Meet Acme Billing\",\n  fontSize = 72,\n  color,\n  fontWeight = 600,\n  theme,\n  mode,\n  initialScale = 2.3,\n  introDuration = 6,\n  holdDuration = 12,\n  pushScale = 1.06,\n  recedeDuration = 14,\n  assembleDuration = 30,\n  wordDelay = 7,\n  wordStagger = 4,\n  wordDuration = 14,\n  wordPush = 0.5,\n  wordFade = 2,\n  letterSpacing = \"-0.03em\",\n  speed = 1,\n  className,\n}: TextRevealProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const fill = color ?? t.foreground;\n\n  // The lead word is centred in the frame while big, then lands at its natural\n  // spot in the line. That needs the line's rendered width and where the lead\n  // word's centre sits inside it — both constant across frames, so measure once\n  // and hold the render until they're known.\n  const lineRef = useRef<HTMLSpanElement>(null);\n  const leadRef = useRef<HTMLSpanElement>(null);\n  const [handle] = useState(() => delayRender(\"text-reveal: measure line\"));\n  const baselineRef = useRef<HTMLSpanElement>(null);\n  const [metrics, setMetrics] = useState<{\n    lineWidth: number;\n    leadRatio: number;\n    /** Distance from the top of the line box down to the text baseline. */\n    baseline: number;\n  } | null>(null);\n\n  // Measure the natural line geometry. offsetWidth / offsetLeft are layout px,\n  // unaffected by the animated transform or the Player's display scaling, so they\n  // give the unscaled line width and the lead word's centre directly.\n  useEffect(() => {\n    const line = lineRef.current;\n    const lead = leadRef.current;\n    if (!line || !lead) {\n      continueRender(handle);\n      return;\n    }\n    const lineWidth = line.offsetWidth;\n    const leadCenter = lead.offsetLeft + lead.offsetWidth / 2;\n    // An empty, zero-sized inline-block sits with its bottom edge on the text\n    // baseline, so its offsetTop *is* the baseline — read from the font's real\n    // metrics rather than guessed from a line-height ratio.\n    const baseline = baselineRef.current?.offsetTop ?? line.offsetHeight * 0.8;\n    setMetrics({ lineWidth, leadRatio: leadCenter / lineWidth, baseline });\n  }, [handle]);\n\n  // Release the render only after the measurement has re-rendered, so the very\n  // first captured frame already carries the correct geometry (no blank frame).\n  useEffect(() => {\n    if (metrics) continueRender(handle);\n  }, [metrics, handle]);\n\n  const words = text.split(\" \").filter(Boolean);\n\n  /**\n   * Two things make a browser's scaled text look stuck and shaky, and both are\n   * the type being quantised rather than the animation being wrong.\n   *\n   * **The stick.** The glyph rasteriser snaps each glyph's origin to the pixel\n   * grid — quarter-pixel precision horizontally, and none at all vertically. So a\n   * scale that moves the baseline makes the type climb the grid in whole-pixel\n   * steps: during the slow ends of an eased curve the baseline drifts a fraction\n   * of a pixel per frame, which rounds to nothing for several frames and then to a\n   * whole pixel at once. Pivoting the scale *on the baseline* means the baseline's\n   * device Y never changes, so there is nothing to snap.\n   *\n   * **The shake.** Hinting distorts each glyph's outline so its stems land on\n   * whole pixels. As the size slides, every stem re-snaps to a different grid and\n   * the letterforms visibly change shape frame to frame — they boil.\n   * `text-rendering: geometricPrecision` turns hinting off and renders the outline\n   * as it actually is.\n   *\n   * Measured on the sibling component: vertical judder 0.296px → 0.005px, and the\n   * shape invariant steadied from 3.41% drift to 0.22%.\n   */\n\n  const ready = metrics !== null;\n  const leadRatio = metrics?.leadRatio ?? 0.14;\n  const lineWidth = metrics?.lineWidth ?? width * 0.5;\n  // Distance from the top of the line box down to the text baseline — the one\n  // point the scale must not move.\n  const baseline = metrics?.baseline ?? fontSize * 0.88;\n\n  // The lead word falls back and the line starts sliding on the same frame.\n  const zoomStart = holdDuration;\n  // How far the line has to travel to carry the lead word's centre from the\n  // frame centre to its natural spot in the sentence.\n  const slideDistance = lineWidth * (0.5 - leadRatio);\n\n  return (\n    <AbsoluteFill\n      style={{\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        background: \"transparent\",\n      }}\n    >\n      <span\n        ref={lineRef}\n        className={className}\n        style={{\n          position: \"relative\",\n          display: \"inline-block\",\n          fontSize,\n          fontWeight,\n          color: fill,\n          letterSpacing,\n          lineHeight: 1.1,\n          whiteSpace: \"nowrap\",\n          fontFamily:\n            \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\",\n          // Pivot on the lead word's centre horizontally — so the line scales out\n          // from that word and it stays planted — and on the baseline vertically,\n          // which is the point that must not move. See above.\n          transformOrigin: `${leadRatio * 100}% ${baseline}px`,\n          // Drift toward the viewer, then the fall back. The two curves never\n          // overlap — the first clamps at the peak once the recede starts, the\n          // second is 0 until then — so they sum into one continuous scale\n          // running initialScale → peak → 1.\n          scale:\n            interpolate(\n              frame,\n              [0, holdDuration],\n              [initialScale, initialScale * pushScale],\n              {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n                easing: PUSH_EASE,\n              },\n            ) +\n            interpolate(\n              frame,\n              [zoomStart, zoomStart + recedeDuration],\n              [0, 1 - initialScale * pushScale],\n              {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n                easing: ZOOM_EASE,\n              },\n            ),\n          // The slide left: same start frame as the recede, but it runs about\n          // twice as long on a heavily eased-in curve, so the line holds its\n          // ground while the lead word falls back and only travels once it has\n          // landed.\n          translate: `${interpolate(\n            frame,\n            [zoomStart, zoomStart + assembleDuration],\n            [slideDistance, 0],\n            {\n              extrapolateLeft: \"clamp\",\n              extrapolateRight: \"clamp\",\n              easing: ZOOM_EASE,\n            },\n          )}px`,\n          opacity: ready ? 1 : 0,\n          // Hinting off: renders each outline as it actually is, so the\n          // letterforms stop re-snapping to the pixel grid as the size slides.\n          // Also forces sub-pixel glyph positioning, which Blink otherwise only\n          // enables above a device scale factor of 1 — and a render is exactly 1.\n          textRendering: \"geometricPrecision\",\n          // Rendering and live playback want opposite things here, so give each\n          // what it needs.\n          //\n          // A render has no time budget: the browser re-shapes and re-rasterises\n          // every glyph at every new size, which is why the type stays genuinely\n          // crisp, and the two fixes above are what make that smooth. Compositing\n          // it would only replace real type with a rescaled texture — and worse,\n          // a render is spread across parallel browser tabs, each of which\n          // inherits a stale raster from whatever scale it drew last, so the same\n          // frame comes out differently depending on which tab drew it.\n          //\n          // The Player is the opposite: one continuous tab, and roughly eight\n          // milliseconds a frame on a 120Hz screen. Re-shaping a line of type at\n          // a brand-new size every frame is the most expensive way to draw text,\n          // and a frame that misses the budget is simply shown for the wrong\n          // length of time — stutter that no amount of CSS can fix. So for live\n          // playback, hand the scale to the compositor: measured on a single\n          // continuous tab it is smooth (0.016px of judder) with no loss of\n          // sharpness at all.\n          ...(getRemotionEnvironment().isRendering\n            ? null\n            : { willChange: \"transform\" as const }),\n        }}\n      >\n        {words.map((word, i) => {\n          const isLead = i === 0;\n          // Each trailing word sits out most of the recede, then pushes in from\n          // the right into its slot. The lead word is the anchor: it never\n          // pushes, and it is the only word visible until `wordDelay`.\n          const pushStart = zoomStart + wordDelay + (i - 1) * wordStagger;\n          const opacity = isLead\n            ? interpolate(frame, [0, introDuration], [0, 1], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n              })\n            : interpolate(frame, [pushStart, pushStart + wordFade], [0, 1], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n              });\n          return (\n            <span\n              // biome-ignore lint/suspicious/noArrayIndexKey: words are positional and never reorder\n              key={i}\n              ref={isLead ? leadRef : undefined}\n              style={{\n                display: \"inline-block\",\n                opacity,\n                translate: isLead\n                  ? undefined\n                  : `${interpolate(\n                      frame,\n                      [pushStart, pushStart + wordDuration],\n                      [wordPush * fontSize, 0],\n                      {\n                        extrapolateLeft: \"clamp\",\n                        extrapolateRight: \"clamp\",\n                        easing: WORD_EASE,\n                      },\n                    )}px`,\n              }}\n            >\n              {word}\n              {i < words.length - 1 ? \" \" : \"\"}\n            </span>\n          );\n        })}\n        {/* Baseline ruler. An empty, zero-sized inline-block aligns its bottom\n            edge to the text baseline, so `offsetTop` reads the baseline straight\n            off the font's real metrics. Zero-sized, so it changes no layout. */}\n        <span\n          ref={baselineRef}\n          style={{ display: \"inline-block\", width: 0, height: 0 }}\n        />\n      </span>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/text-reveal.tsx"
    }
  ],
  "type": "registry:component"
}