{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-build",
  "title": "Text Build",
  "description": "Words enter one at a time while already-placed words physically reflow to stay centered — a growing line on the x axis or a growing stack on the y axis.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/text-build/index.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Easing, interpolate, useCurrentFrame } from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme } from \"@/lib/snap-cn-ui\";\n\nexport type TextBuildAxis = \"x\" | \"y\";\n\nexport interface TextBuildProps {\n  text: string;\n  /** Build direction: \"x\" pushes the line sideways, \"y\" stacks rows. */\n  axis?: TextBuildAxis;\n  /** Distance a new word travels in from (px). Defaults: 88 on x, 28 on y. */\n  entryOffset?: number;\n  /** Space between words (px on x) or between rows (px on y, default 12). */\n  gap?: number;\n  /** Frames the first word takes to settle. */\n  firstDuration?: number;\n  /** Frames each subsequent enter/push/reflow step takes. */\n  pushDuration?: number;\n  /** Scale a word enters at before settling to 1. */\n  entryScale?: number;\n  /** Blur (px) a word enters with. Defaults: 3.5 on x, 2.4 on y. */\n  entryBlur?: number;\n  /** Peak blur (px) on already-placed words while they reflow. */\n  reflowBlur?: number;\n  /** Cubic-bezier easing for every move. */\n  easing?: [number, number, number, 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\nconst FONT_FAMILY =\n  \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\";\n\nconst MEASURE_FONT =\n  '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif';\n\n/** Per-axis defaults harvested from the two legacy looks. */\nconst AXIS_DEFAULTS: Record<\n  TextBuildAxis,\n  {\n    entryOffset: number;\n    gap: number;\n    entryBlur: number;\n    /** Subtle drift the very first word settles from, on the cross axis (x)\n     * or the primary axis (y). */\n    firstDrift: number;\n  }\n> = {\n  x: { entryOffset: 88, gap: 10, entryBlur: 3.5, firstDrift: 6 },\n  y: { entryOffset: 28, gap: 12, entryBlur: 2.4, firstDrift: -14 },\n};\n\n/**\n * Size of each word along the build axis. On \"x\" this is the measured text\n * width (canvas measureText, with a `length * fontSize * 0.55` SSR fallback);\n * on \"y\" every row is one `fontSize` tall.\n */\nfunction fallbackWordSizes(\n  words: string[],\n  axis: TextBuildAxis,\n  fontSize: number,\n): number[] {\n  if (axis === \"y\") return words.map(() => fontSize);\n  return words.map((w) => w.length * fontSize * 0.55);\n}\n\nexport function measureWordSizes(\n  words: string[],\n  axis: TextBuildAxis,\n  fontSize: number,\n  fontWeight: number,\n): number[] {\n  if (axis === \"y\") return words.map(() => fontSize);\n  if (typeof document === \"undefined\")\n    return fallbackWordSizes(words, axis, fontSize);\n  const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n  if (!ctx) return fallbackWordSizes(words, axis, fontSize);\n  ctx.font = `${fontWeight} ${fontSize}px ${MEASURE_FONT}`;\n  return words.map((w) => ctx.measureText(w).width);\n}\n\n/**\n * Center coordinates for every intermediate layout. `result[k - 1][j]` is the\n * centered position of word `j` while the first `k` words are on stage, so\n * placed words can be interpolated between consecutive layouts as new words\n * land.\n */\nexport function centeredPositions(sizes: number[], gap: number): number[][] {\n  const out: number[][] = [];\n  for (let k = 1; k <= sizes.length; k++) {\n    let total = gap * (k - 1);\n    for (let j = 0; j < k; j++) total += sizes[j];\n    let cursor = -total / 2;\n    const centers: number[] = [];\n    for (let j = 0; j < k; j++) {\n      centers.push(cursor + sizes[j] / 2);\n      cursor += sizes[j] + gap;\n    }\n    out.push(centers);\n  }\n  return out;\n}\n\nexport function TextBuild({\n  text,\n  axis = \"x\",\n  entryOffset,\n  gap,\n  firstDuration = 10,\n  pushDuration = 13,\n  entryScale = 0.992,\n  entryBlur,\n  reflowBlur = 0.8,\n  easing = [0.2, 0.8, 0.2, 1],\n  fontSize = 72,\n  color,\n  fontWeight = 600,\n  speed = 1,\n  className,\n  theme,\n  mode,\n}: TextBuildProps) {\n  const frame = useCurrentFrame() * speed;\n  const t = useSnapCnTheme(theme, mode);\n  const fill = color ?? t.foreground;\n\n  const defaults = AXIS_DEFAULTS[axis];\n  const offset = entryOffset ?? defaults.entryOffset;\n  const spacing = gap ?? defaults.gap;\n  const inBlur = entryBlur ?? defaults.entryBlur;\n\n  const words = useMemo(() => text.split(\" \"), [text]);\n\n  // Canvas text measurement isn't available during SSR, so the first client\n  // render must also use the fallback estimate (matching the server-rendered\n  // markup) before upgrading to the precise canvas measurement post-mount —\n  // otherwise the two renders disagree and React logs a hydration mismatch.\n  const [measured, setMeasured] = useState(false);\n  useEffect(() => setMeasured(true), []);\n  const sizes = useMemo(\n    () =>\n      measured\n        ? measureWordSizes(words, axis, fontSize, fontWeight)\n        : fallbackWordSizes(words, axis, fontSize),\n    [measured, words, axis, fontSize, fontWeight],\n  );\n  const positionsAt = useMemo(\n    () => centeredPositions(sizes, spacing),\n    [sizes, spacing],\n  );\n\n  const n = words.length;\n  const ease = Easing.bezier(easing[0], easing[1], easing[2], easing[3]);\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        background: \"transparent\",\n      }}\n    >\n      <div\n        className={className}\n        style={{\n          position: \"relative\",\n          fontSize,\n          fontWeight,\n          color: fill,\n          letterSpacing: \"-0.03em\",\n          fontFamily: FONT_FAMILY,\n          whiteSpace: \"nowrap\",\n        }}\n      >\n        {words.map((word, j) => {\n          const entryStart =\n            j === 0 ? 0 : firstDuration + (j - 1) * pushDuration;\n          const entryEnd =\n            entryStart + (j === 0 ? firstDuration : pushDuration);\n          const target = positionsAt[j][j];\n\n          // Where the word enters from. Later words travel `offset` along the\n          // build axis (from the right on x, from above on y). The first word\n          // only drifts subtly: cross-axis on x, a short drop on y.\n          const primaryFrom =\n            j === 0\n              ? axis === \"y\"\n                ? target + defaults.firstDrift\n                : target\n              : axis === \"y\"\n                ? target - offset\n                : target + offset;\n          const crossFrom = j === 0 && axis === \"x\" ? defaults.firstDrift : 0;\n\n          let primary = target;\n          let cross = 0;\n          let opacity = 1;\n          let scale = 1;\n          let blur = 0;\n\n          if (frame < entryStart) {\n            opacity = 0;\n            primary = primaryFrom;\n            cross = crossFrom;\n            scale = entryScale;\n            blur = inBlur;\n          } else if (frame <= entryEnd) {\n            const range: [number, number] = [entryStart, entryEnd];\n            const opts = {\n              extrapolateLeft: \"clamp\" as const,\n              extrapolateRight: \"clamp\" as const,\n              easing: ease,\n            };\n            primary = interpolate(frame, range, [primaryFrom, target], opts);\n            cross = interpolate(frame, range, [crossFrom, 0], opts);\n            opacity = interpolate(frame, range, [0, 1], opts);\n            scale = interpolate(frame, range, [entryScale, 1], opts);\n            blur = interpolate(frame, range, [inBlur, 0], opts);\n          } else {\n            // Placed word: reflow between consecutive layouts as each later\n            // word lands, with a soft blur pulse mid-move.\n            for (let w = j + 1; w < n; w++) {\n              const pushStart = firstDuration + (w - 1) * pushDuration;\n              const pushEnd = pushStart + pushDuration;\n              const from = positionsAt[w - 1][j];\n              const to = positionsAt[w][j];\n              if (frame >= pushEnd) {\n                primary = to;\n              } else if (frame >= pushStart) {\n                primary = interpolate(frame, [pushStart, pushEnd], [from, to], {\n                  extrapolateLeft: \"clamp\",\n                  extrapolateRight: \"clamp\",\n                  easing: ease,\n                });\n                blur = interpolate(\n                  frame,\n                  [pushStart, (pushStart + pushEnd) / 2, pushEnd],\n                  [0, reflowBlur, 0],\n                  { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n                );\n                break;\n              } else {\n                primary = from;\n                break;\n              }\n            }\n          }\n\n          const tx = axis === \"x\" ? primary : 0;\n          const ty = axis === \"x\" ? cross : primary;\n\n          return (\n            <span\n              key={`${j}-${word}`}\n              style={{\n                position: \"absolute\",\n                left: \"50%\",\n                top: \"50%\",\n                whiteSpace: \"nowrap\",\n                backfaceVisibility: \"hidden\",\n                transform: `translate(-50%, -50%) translate3d(${tx}px, ${ty}px, 0) scale(${scale})`,\n                filter: `blur(${blur}px)`,\n                opacity,\n              }}\n            >\n              {word}\n            </span>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/text-build.tsx"
    }
  ],
  "type": "registry:component"
}