{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chunk-stack",
  "title": "Chunk Stack",
  "description": "\"learn motion by making it\" stacks one word per line with a yellow highlighter on \"making\".",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/chunk-stack/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Nunito\";\nimport type React from \"react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst nunito = loadFont(\"normal\", {\n  weights: [\"500\"],\n  subsets: [\"latin\"],\n});\n\nexport interface ChunkStackProps {\n  text?: string;\n  highlight?: string;\n  accent?: string;\n  background?: string;\n  speed?: number;\n  theme?: \"dark\" | \"light\";\n}\n\nconst THEMES = {\n  dark: {\n    bg: \"#133A24\",\n    ink: \"#EAF7EC\",\n    accent: \"#FFD23F\",\n    pillInk: \"#133A24\",\n  },\n  light: {\n    bg: \"#FFD23F\",\n    ink: \"#133A24\",\n    accent: \"#FFFFFF\",\n    pillInk: \"#133A24\",\n  },\n} as const;\n\nconst FONT_PX = 200;\nconst TRACK = -0.025;\nconst LEAD = 0.98;\nconst RISE_LEN = 16;\nconst OPEN_AT = 26;\nconst OPEN_GAP = 3;\nconst OPEN_LEN = 14;\nconst DROP_AT = 50;\nconst DROP_GAP = 4;\nconst DROP_LEN = 22;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_IN_OUT = Easing.bezier(0.65, 0, 0.25, 1);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\ninterface Metrics {\n  run: number[];\n  runW: number;\n  words: number[];\n  space: number;\n  asc: number;\n  desc: number;\n  xh: number;\n}\n\nfunction measure(words: string[]): Metrics {\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) throw new Error(\"chunk-stack: no 2d context\");\n  ctx.font = `500 ${FONT_PX}px \"${nunito.fontFamily}\"`;\n  const w = (s: string) =>\n    ctx.measureText(s).width / FONT_PX +\n    TRACK * Math.max(0, Array.from(s).length - 1);\n  const run: number[] = [];\n  let prefix = \"\";\n  for (const word of words) {\n    run.push(prefix ? w(prefix) + TRACK : 0);\n    prefix += word;\n  }\n  const fm = ctx.measureText(\"Hg\");\n  return {\n    run,\n    runW: w(prefix),\n    words: words.map(w),\n    space: ctx.measureText(\" \").width / FONT_PX,\n    asc: fm.fontBoundingBoxAscent / FONT_PX,\n    desc: fm.fontBoundingBoxDescent / FONT_PX,\n    xh: ctx.measureText(\"x\").actualBoundingBoxAscent / FONT_PX,\n  };\n}\n\nfunction useMetrics(words: string[]) {\n  const key = words.join(\" \");\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  const [handle] = useState(() => delayRender(\"chunk-stack: measuring\"));\n  const released = useRef(false);\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      nunito.waitUntilDone(),\n      document.fonts.load(`500 100px \"${nunito.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setMetrics(measure(key.split(\" \")));\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [key, handle]);\n  return metrics;\n}\n\nfunction impact(u: number) {\n  if (u < 0) return 0;\n  return Math.exp(-u / 3.4) * Math.cos(u * 0.6);\n}\n\nexport function ChunkStack({\n  text = \"learn motion by making it\",\n  highlight = \"making\",\n  accent,\n  background,\n  speed = 1,\n  theme = \"dark\",\n}: ChunkStackProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H } = useVideoConfig();\n  const c = THEMES[theme];\n  const bg = background ?? c.bg;\n  const pill = accent ?? c.accent;\n  const words = useMemo(() => text.trim().split(/\\s+/).filter(Boolean), [text]);\n  const m = useMetrics(words);\n\n  if (!m || words.length === 0) {\n    return <div style={{ position: \"absolute\", inset: 0, background: bg }} />;\n  }\n\n  const n = words.length;\n  const gapOpen = m.space * 1.35;\n  const spacedW = m.runW + gapOpen * (n - 1);\n  const openAll = interpolate(\n    t,\n    [OPEN_AT, OPEN_AT + (n - 2) * OPEN_GAP + OPEN_LEN],\n    [0, 1],\n    { ...CLAMP, easing: EASE_IN_OUT },\n  );\n  const lineSize = Math.min(\n    (W * 0.9) / (m.runW + (spacedW - m.runW) * openAll),\n    H * 0.2,\n  );\n  const maxWord = Math.max(...m.words);\n  const stackSize = Math.min(\n    (W * 0.82) / maxWord,\n    (H * 0.84) / (LEAD * (n - 1) + m.xh + 0.32),\n    H * 0.3,\n  );\n  const R = Math.max(lineSize, stackSize);\n  const lh = R * 1.4;\n  const bo = (lh - (m.asc + m.desc) * R) / 2 + m.asc * R;\n\n  const lineBase = H / 2 + (m.xh * lineSize) / 2;\n  const stackH = (LEAD * (n - 1) + m.xh) * stackSize;\n  const stackTop = (H - stackH) / 2 + m.xh * stackSize;\n\n  const rise = interpolate(t, [0, RISE_LEN], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n\n  const opens = Array.from({ length: n }, (_, k) =>\n    k === 0\n      ? 0\n      : interpolate(\n          t,\n          [\n            OPEN_AT + (k - 1) * OPEN_GAP,\n            OPEN_AT + (k - 1) * OPEN_GAP + OPEN_LEN,\n          ],\n          [0, 1],\n          { ...CLAMP, easing: EASE_OUT },\n        ),\n  );\n  let shift = 0;\n  const lineX: number[] = [];\n  for (let k = 0; k < n; k++) {\n    shift += opens[k] * gapOpen;\n    lineX.push(m.run[k] + shift);\n  }\n  const curW = m.runW + shift;\n  const lineLeft = (W - curW * lineSize) / 2;\n\n  const hlIndex = words.findIndex(\n    (w) =>\n      w.replace(/[^\\p{L}\\p{N}]/gu, \"\") ===\n      highlight.replace(/[^\\p{L}\\p{N}]/gu, \"\"),\n  );\n\n  const nodes: React.ReactNode[] = [];\n  let pillNode: React.ReactNode = null;\n  for (let k = 0; k < n; k++) {\n    const start = DROP_AT + k * DROP_GAP;\n    const dv = interpolate(t, [DROP_AT + k * 2, DROP_AT + k * 2 + 12], [0, 1], {\n      ...CLAMP,\n      easing: EASE_IN_OUT,\n    });\n    const d = interpolate(t, [start + 6, start + DROP_LEN], [0, 1], {\n      ...CLAMP,\n      easing: EASE_IN_OUT,\n    });\n    const x0 = lineLeft + lineX[k] * lineSize + (m.words[k] * lineSize) / 2;\n    const y0 = lineBase;\n    const x1 = W / 2;\n    const y1 = stackTop + k * LEAD * stackSize;\n    const size = lineSize + (stackSize - lineSize) * d;\n    const cx = x0 + (x1 - x0) * d;\n    const by = y0 + (y1 - y0) * dv;\n    const q = impact(t - start - DROP_LEN);\n    const sx = 1 + 0.08 * q;\n    const sy = 1 - 0.12 * q;\n    const s = size / R;\n    const wpx = m.words[k] * R;\n    const risePx = (1 - rise) * lineSize * 1.3;\n\n    if (k === hlIndex) {\n      const pp = interpolate(\n        t,\n        [start + DROP_LEN - 2, start + DROP_LEN + 10],\n        [0, 1],\n        { ...CLAMP, easing: EASE_OUT },\n      );\n      if (pp > 0) {\n        const padX = 0.2 * stackSize;\n        const pw = m.words[k] * stackSize + padX * 2;\n        const ph = (m.xh + 0.62) * stackSize;\n        const top = y1 - (m.xh + 0.3) * stackSize;\n        pillNode = (\n          <div\n            key=\"pill\"\n            style={{\n              position: \"absolute\",\n              left: x1 - pw / 2,\n              top,\n              width: pw,\n              height: ph,\n              borderRadius: ph * 0.34,\n              background: pill,\n              opacity: pp,\n              transform: `scale(${(0.9 + 0.1 * pp) * sx}, ${(0.7 + 0.3 * pp) * sy})`,\n              transformOrigin: `50% ${((m.xh + 0.3) * stackSize).toFixed(1)}px`,\n            }}\n          />\n        );\n      }\n    }\n\n    const hlOn =\n      k === hlIndex\n        ? interpolate(\n            t,\n            [start + DROP_LEN - 1, start + DROP_LEN + 6],\n            [0, 1],\n            CLAMP,\n          )\n        : 0;\n    nodes.push(\n      <div\n        key={`w${k}`}\n        style={{\n          position: \"absolute\",\n          left: cx - wpx / 2,\n          top: by - bo + risePx,\n          width: wpx,\n          height: lh,\n          fontFamily: nunito.fontFamily,\n          fontWeight: 500,\n          fontSize: R,\n          lineHeight: `${lh}px`,\n          letterSpacing: `${TRACK}em`,\n          whiteSpace: \"pre\",\n          color: hlOn > 0.5 ? c.pillInk : c.ink,\n          transform: `scale(${(s * sx).toFixed(4)}, ${(s * sy).toFixed(4)})`,\n          transformOrigin: `50% ${bo.toFixed(1)}px`,\n        }}\n      >\n        {words[k]}\n      </div>,\n    );\n  }\n\n  return (\n    <AbsoluteFill style={{ background: bg, overflow: \"hidden\" }}>\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          clipPath:\n            t < RISE_LEN\n              ? `inset(0 0 ${(H - lineBase - m.desc * lineSize * 0.9).toFixed(1)}px 0)`\n              : undefined,\n        }}\n      >\n        {pillNode}\n        {nodes}\n      </div>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/chunk-stack.tsx"
    }
  ],
  "type": "registry:component"
}
