{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-swell",
  "title": "Text Swell",
  "description": "A title reveal where the lead word floats toward the viewer and hangs there while the sentence assembles around it — each trailing word pushes in and its letters bounce up off the baseline in a wave — before the whole line falls back to size.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/text-swell/index.tsx",
      "content": "\"use client\";\n\nimport { Fragment, 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 { type SnapCnTheme, useSnapCnTheme } from \"@/lib/snap-cn-ui\";\n\nexport interface TextSwellProps {\n  /** The sentence to assemble. Its first word leads, and everything else pushes it left. */\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\n  /** Frames the lead word fades in over. */\n  introDuration?: number;\n  /** How far below its resting line the lead word starts, in em. */\n  riseDistance?: number;\n  /** Frames the lead word takes to rise into place. */\n  riseDuration?: number;\n\n  /** Size the lead word rises in at, relative to its final size. */\n  startScale?: number;\n  /**\n   * How far forward the line floats (2.1 = just over twice its final size).\n   * Capped so the line never leaves the frame — see `frontScale` in the docs.\n   */\n  frontScale?: number;\n  /** Frames after the start before the line begins floating forward. */\n  approachDelay?: number;\n  /** Frames the line takes to float forward. */\n  approachDuration?: number;\n\n  /**\n   * Frames before the second word arrives. The lead word is alone until then, so\n   * this is the beat between it landing and the sentence starting to build.\n   */\n  wordDelay?: number;\n  /** Frames between each trailing word arriving. */\n  wordStagger?: number;\n  /** How far right of its slot a trailing word starts, in em. */\n  wordPush?: number;\n  /**\n   * Frames a trailing word takes to push into its slot. The lead word is shoved\n   * left over the same window, so the push and the shove are one motion.\n   */\n  wordPushDuration?: number;\n\n  /**\n   * How many trailing words bounce their letters in. 1 (the default) bounces\n   * only the second word; the rest simply push in. The lead word never bounces.\n   */\n  bounceWords?: number;\n  /** How far each letter swells at the peak of its bounce (0.23 = 23% bigger). */\n  letterSwell?: number;\n  /** Frames between one letter starting its bounce and the next. */\n  letterStagger?: number;\n  /** Frames a letter takes to swell up. */\n  letterRise?: number;\n  /** Frames a letter stays swollen at the top of its bounce. */\n  letterHold?: number;\n  /** Frames a letter takes to settle back to its natural size. */\n  letterFall?: number;\n\n  /** Frames the line stays forward once everything has landed. */\n  holdDuration?: number;\n  /** Frames the whole line takes to fall back to its final size. */\n  recedeDuration?: number;\n\n  /** Resting letter-spacing (CSS value, em recommended). */\n  letterSpacing?: string;\n  speed?: number;\n  className?: string;\n}\n\n/**\n * The lead word rising into place: already moving, decelerating to a standstill.\n *\n * A *moderate* decelerate, deliberately — not the quint/expo-out that this kind\n * of entrance usually reaches for. Those asymptote: they cover 99% of the travel\n * in the first third and then crawl. Over a 50px rise at 30fps that leaves five\n * frames moving less than half a pixel each, which rasterise to identical frames\n * — the word visibly stops dead partway up and then the next beat starts. This\n * curve still arrives at a standstill, but it spends its frames on travel you can\n * see. The general rule for anything on a frame clock: a settle worth one frame\n * is a settle; a settle worth five frames is a freeze.\n */\nconst RISE_EASE = Easing.bezier(0.2, 0.6, 0.35, 1);\n\n/** The line floating forward: eases in, decelerates into a hang. */\nconst APPROACH_EASE = Easing.bezier(0.4, 0, 0.15, 1);\n\n/**\n * The fall back. Heavily eased in — barely 10% travelled a third of the way\n * through — so the line hangs a moment longer than you expect and then carries\n * to a dead stop. No bounce.\n */\nconst ZOOM_EASE = Easing.bezier(0.5, 0, 0.05, 1);\n\n/**\n * A word pushing into its slot, and the shove it gives the line. Quick off the\n * mark, long soft landing.\n */\nconst WORD_EASE = Easing.bezier(0.22, 0.8, 0.36, 1);\n\n/**\n * Both halves of a letter's bounce. It leaves and arrives at a standstill, so\n * the letter eases off its baseline, rounds over the top and eases back down\n * with no kink at either end and no jerk on the way up.\n */\nconst LETTER_EASE = Easing.bezier(0.4, 0, 0.2, 1);\n\n/** Fraction of the frame width the line may occupy. */\nconst FIT = 0.97;\n\n/**\n * A title reveal built around one idea: the lead word is pushed aside by the\n * words that follow it.\n *\n * 1. The lead word rises from below and settles, centred, at its natural size.\n * 2. The line floats forward — toward the viewer.\n * 3. The second word cuts in from the right, its letters bouncing up off the\n *    baseline one after another, and **shoves the lead word left** to make room.\n *    Later words follow, pushing in without a bounce, each shoving the lead word\n *    further left.\n * 4. Once everything has landed, the whole line falls back to its final size and\n *    settles into the sentence.\n *\n * The shove is the point, so it is not on a clock of its own. Each word owns a\n * share of the leftward travel — its share of the width it adds to the line —\n * and spends that share over exactly the frames it spends pushing into its slot.\n * The lead word therefore cannot move until a word arrives to move it, and it\n * lands where the finished sentence needs it to be.\n *\n * Only `scale`, `translate` and `opacity` animate — nothing reflows — so the\n * baseline is fixed and there is no layout shift.\n */\nexport function TextSwell({\n  text = \"No extra charge\",\n  fontSize = 72,\n  color,\n  fontWeight = 600,\n  theme,\n  mode,\n  introDuration = 8,\n  riseDistance = 0.7,\n  riseDuration = 10,\n  startScale = 1,\n  frontScale = 2.1,\n  approachDelay = 14,\n  approachDuration = 20,\n  wordDelay = 27,\n  wordStagger = 14,\n  wordPush = 0.15,\n  wordPushDuration = 12,\n  bounceWords = 1,\n  letterSwell = 0.23,\n  letterStagger = 2,\n  letterRise = 3,\n  letterHold = 0,\n  letterFall = 6,\n  holdDuration = 6,\n  recedeDuration = 18,\n  letterSpacing = \"-0.03em\",\n  speed = 1,\n  className,\n}: TextSwellProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const fill = color ?? t.foreground;\n\n  const words = text.split(\" \").filter(Boolean);\n\n  /**\n   * Why the scale is pivoted on the baseline, and not on the middle of the line.\n   *\n   * Browsers do not scale text the way they scale an image. They re-shape and\n   * re-rasterise the glyphs at every new size, and the rasteriser snaps each\n   * glyph's origin to the pixel grid: horizontally it gets quarter-pixel\n   * precision, vertically it gets **none at all** — the origin rounds to a whole\n   * device pixel. So a scale that moves the baseline makes the type climb the\n   * pixel grid in whole-pixel steps. During the slow ends of an eased curve the\n   * baseline drifts by a fraction of a pixel per frame, which rounds to *nothing*\n   * for several frames and then to a whole pixel all at once: the letters sit\n   * still, jump, sit still. That is the \"stuck\", and no amount of easing work\n   * fixes it, because it is the type being quantised, not the animation.\n   *\n   * Pivot on the baseline and the baseline's device Y simply never changes, so\n   * there is nothing to snap. The glyphs still re-rasterise at every size — which\n   * is what we want, it is why they stay crisp — they just stop moving vertically\n   * while doing it. Swept across a linear 1.6x → 1x ramp, the line's vertical\n   * judder falls from 0.284px (pivoting on the middle, 29 direction reversals in\n   * 40 frames) to 0.014px with zero reversals, and the minimum sits exactly on\n   * the baseline and nowhere else.\n   *\n   * The alternative — compositing the line so the GPU resamples a bitmap — also\n   * removes the snapping, but it stops the type being re-rasterised at all: what\n   * you get on screen is a rescaled texture, softer than real type at every size\n   * but one. Crisp and smooth beats smooth alone.\n   *\n   * The visible cost is that the line now grows *upward* off its baseline rather\n   * than outward from its middle, which shifts the ink about 10px at full size on\n   * a 720p frame. Type sitting on a line and growing off it is the more\n   * typographic read anyway.\n   */\n\n  // The lead word sits in the middle of the frame while it is alone, and every\n  // word after it shoves the line left by the width it adds. That needs the\n  // line's rendered width and where each word ends inside it — all constant\n  // across frames, so measure once and hold the render until they are known.\n  // offsetLeft / offsetWidth are layout px, untouched by the animated\n  // transforms, so they give the unscaled geometry.\n  const lineRef = useRef<HTMLSpanElement>(null);\n  const wordRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const [handle] = useState(() => delayRender(\"text-swell: measure line\"));\n  const baselineRef = useRef<HTMLSpanElement>(null);\n  const [metrics, setMetrics] = useState<{\n    lineWidth: number;\n    leadCenter: number;\n    /** Right edge of each word, in unscaled line-local px. */\n    ends: number[];\n    /** Distance from the top of the line box down to the text baseline. */\n    baseline: number;\n  } | null>(null);\n\n  useEffect(() => {\n    const line = lineRef.current;\n    const spans = wordRefs.current.slice(0, words.length);\n    if (!line || spans.length !== words.length || spans.some((s) => !s)) {\n      continueRender(handle);\n      return;\n    }\n    const lead = spans[0] as HTMLSpanElement;\n    // An empty, zero-sized inline-block sits with its bottom edge on the text\n    // baseline, so its offsetTop *is* the baseline — measured from the real font\n    // metrics rather than guessed from a line-height ratio.\n    const baseline = baselineRef.current?.offsetTop ?? line.offsetHeight * 0.8;\n    setMetrics({\n      lineWidth: line.offsetWidth,\n      leadCenter: lead.offsetLeft + lead.offsetWidth / 2,\n      ends: spans.map((s) => {\n        const el = s as HTMLSpanElement;\n        return el.offsetLeft + el.offsetWidth;\n      }),\n      baseline,\n    });\n  }, [handle, words.length]);\n\n  // Release the render only once 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 ready = metrics !== null;\n  const lineWidth = metrics?.lineWidth ?? width * 0.4;\n  const leadCenter = metrics?.leadCenter ?? lineWidth * 0.08;\n  const ends =\n    metrics?.ends ?? words.map((_, i) => ((i + 1) * lineWidth) / words.length);\n  // Distance from the top of the line box down to the text baseline — the one\n  // point the scale must not move. See the note above.\n  const baseline = metrics?.baseline ?? fontSize * 0.88;\n\n  // Where the line's left edge rests once everything has settled — flexbox\n  // centres the line, and the transform pivots on that same left edge.\n  const restLeft = (width - lineWidth) / 2;\n\n  // Each trailing word owns a share of the leftward shove, proportional to the\n  // width it adds to the line. `before[i]` is how much of the shove is already\n  // spent by the time word `i` arrives.\n  const added = words.map((_, i) => (i === 0 ? 0 : ends[i] - ends[i - 1]));\n  const totalAdded = added.reduce((a, b) => a + b, 0) || 1;\n  const share = added.map((a) => a / totalAdded);\n  const before: number[] = [];\n  for (let i = 0, run = 0; i < words.length; i++) {\n    before.push(run);\n    run += share[i];\n  }\n\n  const wordStart = (i: number) => wordDelay + (i - 1) * wordStagger;\n  const letterStart = (i: number, j: number) =>\n    wordStart(i) + j * letterStagger;\n\n  // Floating forward blows the line up about its left edge, and until the last\n  // word has finished shoving, that edge still sits right of where it will rest.\n  // A word arriving into that gap is the widest the line ever gets, so cap the\n  // float at whatever keeps even that moment inside the frame.\n  let front = Math.min(frontScale, (width * FIT - restLeft) / lineWidth);\n  for (let i = 1; i < words.length; i++) {\n    const unspent = 1 - before[i];\n    const room = width * FIT - restLeft - (lineWidth / 2) * unspent;\n    const reach = ends[i] + wordPush * fontSize - leadCenter * unspent;\n    if (reach > 0) front = Math.min(front, room / reach);\n  }\n  front = Math.max(1, front);\n\n  // Float forward, hang, then fall back. The two curves never overlap — the\n  // first clamps at `front` once the approach ends, the second is 0 until the\n  // recede starts — so they sum into one continuous scale: startScale → front → 1.\n  const lastEnd = Math.max(\n    approachDelay + approachDuration,\n    ...words.map((word, i) =>\n      i === 0\n        ? 0\n        : Math.max(\n            wordStart(i) + wordPushDuration,\n            i <= bounceWords\n              ? letterStart(i, word.length - 1) +\n                  letterRise +\n                  letterHold +\n                  letterFall\n              : 0,\n          ),\n    ),\n  );\n  const recedeStart = lastEnd + holdDuration;\n\n  const scale =\n    interpolate(\n      frame,\n      [approachDelay, approachDelay + approachDuration],\n      [startScale, front],\n      {\n        extrapolateLeft: \"clamp\",\n        extrapolateRight: \"clamp\",\n        easing: APPROACH_EASE,\n      },\n    ) +\n    interpolate(\n      frame,\n      [recedeStart, recedeStart + recedeDuration],\n      [0, 1 - front],\n      {\n        extrapolateLeft: \"clamp\",\n        extrapolateRight: \"clamp\",\n        easing: ZOOM_EASE,\n      },\n    );\n\n  // How much of the leftward shove has been spent. Nothing moves the lead word\n  // but the words arriving to move it, so this is just their pushes added up.\n  let shoved = 0;\n  for (let i = 1; i < words.length; i++) {\n    shoved +=\n      share[i] *\n      interpolate(\n        frame,\n        [wordStart(i), wordStart(i) + wordPushDuration],\n        [0, 1],\n        {\n          extrapolateLeft: \"clamp\",\n          extrapolateRight: \"clamp\",\n          easing: WORD_EASE,\n        },\n      );\n  }\n\n  // Unshoved, the line sits wherever puts the lead word in the middle of the\n  // frame — at any scale, so the word stays centred as it floats forward. Fully\n  // shoved, it sits on its resting left edge, which is where the finished\n  // sentence belongs. `shoved` walks between the two.\n  const translateX = (lineWidth / 2 - scale * leadCenter) * (1 - shoved);\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 left edge, and on the baseline. See above:\n          // the baseline is what must not move.\n          transformOrigin: `0% ${baseline}px`,\n          scale,\n          translate: `${translateX}px`,\n          opacity: ready ? 1 : 0,\n          // The second half of the fix, and the one that stops the *shake* rather\n          // than the stick.\n          //\n          // Hinting distorts each glyph's outline so its stems land on whole\n          // pixels — great for a static paragraph, ruinous for type that is\n          // changing size. As the scale slides, every stem re-snaps to a different\n          // grid, so the letterforms visibly change shape from frame to frame.\n          // They boil. Measured over the fall-back, the line's shape invariant\n          // (ink area over width squared, which for a rigid shape being scaled\n          // literally cannot change) wandered by 3.41% with hinting on and 0.22%\n          // with it off — fifteen times steadier.\n          //\n          // `geometricPrecision` turns hinting off and asks for the outline to be\n          // rendered as it actually is. The type reads very slightly softer,\n          // because the stems are no longer being snapped — that is not blur, it\n          // is the absence of a lie, and it is what every professional motion tool\n          // does with type. It also forces sub-pixel glyph positioning on, which\n          // Blink otherwise only enables above a device scale factor of 1 — and a\n          // Remotion 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          const start = wordStart(i);\n          const bounces = i >= 1 && i <= bounceWords;\n          return (\n            // The gap between words is a plain text node *between* the word\n            // spans, not inside one. A trailing space inside an inline-block sits\n            // at the end of that box's line and CSS strips it, which would run\n            // the sentence together.\n            // biome-ignore lint/suspicious/noArrayIndexKey: words are positional and never reorder\n            <Fragment key={i}>\n              {isLead ? null : \" \"}\n              <span\n                ref={(el) => {\n                  wordRefs.current[i] = el;\n                }}\n                style={{\n                  display: \"inline-block\",\n                  opacity: isLead\n                    ? interpolate(frame, [0, introDuration], [0, 1], {\n                        extrapolateLeft: \"clamp\",\n                        extrapolateRight: \"clamp\",\n                      })\n                    : interpolate(frame, [start, start + 3], [0, 1], {\n                        extrapolateLeft: \"clamp\",\n                        extrapolateRight: \"clamp\",\n                      }),\n                  // The lead word rises from below into the middle. Every other\n                  // word arrives from the right of its slot and pushes in.\n                  translate: isLead\n                    ? `0px ${interpolate(\n                        frame,\n                        [0, riseDuration],\n                        [riseDistance * fontSize, 0],\n                        {\n                          extrapolateLeft: \"clamp\",\n                          extrapolateRight: \"clamp\",\n                          easing: RISE_EASE,\n                        },\n                      )}px`\n                    : `${interpolate(\n                        frame,\n                        [start, start + wordPushDuration],\n                        [wordPush * fontSize, 0],\n                        {\n                          extrapolateLeft: \"clamp\",\n                          extrapolateRight: \"clamp\",\n                          easing: WORD_EASE,\n                        },\n                      )}px`,\n                }}\n              >\n                {/* Only a bouncing word is split into letters — the others stay\n                    one run, which keeps their kerning intact. */}\n                {bounces\n                  ? Array.from(word).map((letter, j) => {\n                      const rise = letterStart(i, j);\n                      const fall = rise + letterRise + letterHold;\n                      return (\n                        <span\n                          // biome-ignore lint/suspicious/noArrayIndexKey: letters are positional and never reorder\n                          key={j}\n                          style={{\n                            display: \"inline-block\",\n                            // Swell up off the baseline and settle back. The rise\n                            // clamps at 1 and the fall is 0 until it starts, so\n                            // the difference is one pulse: 0 → 1 → 0, with\n                            // `letterHold` frames of flat top between them.\n                            // Pivot on the baseline, not on `100%`. The bottom of\n                            // an inline-block sits *below* the baseline by the\n                            // descent, so pivoting there would drag the letter's\n                            // baseline upward as it swells — and a baseline that\n                            // moves is exactly what snaps to the pixel grid.\n                            transformOrigin: `50% ${baseline}px`,\n                            scale:\n                              1 +\n                              letterSwell *\n                                (interpolate(\n                                  frame,\n                                  [rise, rise + letterRise],\n                                  [0, 1],\n                                  {\n                                    extrapolateLeft: \"clamp\",\n                                    extrapolateRight: \"clamp\",\n                                    easing: LETTER_EASE,\n                                  },\n                                ) -\n                                  interpolate(\n                                    frame,\n                                    [fall, fall + letterFall],\n                                    [0, 1],\n                                    {\n                                      extrapolateLeft: \"clamp\",\n                                      extrapolateRight: \"clamp\",\n                                      easing: LETTER_EASE,\n                                    },\n                                  )),\n                          }}\n                        >\n                          {letter}\n                        </span>\n                      );\n                    })\n                  : word}\n              </span>\n            </Fragment>\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-swell.tsx"
    }
  ],
  "type": "registry:component"
}