{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slit-scan",
  "title": "Slit Scan",
  "description": "A word is slit-scanned into view through fluted slices, with a speed stat in the caption.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/slit-scan/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadBricolage } from \"@remotion/google-fonts/BricolageGrotesque\";\nimport { useId } from \"react\";\nimport {\n  AbsoluteFill,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst { fontFamily: bricolage } = loadBricolage(\"normal\", {\n  weights: [\"400\", \"500\"],\n  subsets: [\"latin\"],\n});\n\nexport interface SlitScanProps {\n  word?: string;\n  caption?: string;\n  slices?: number;\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst MOVE_AT = -30;\nconst MOVE_END = 28;\nconst LAG = 34;\nconst RESOLVE_AT = 36;\nconst RESOLVE_END = 76;\nconst CAPTION_AT = 78;\n\nconst MOVE = Easing.bezier(0.3, 0.1, 0.2, 1);\nconst RESOLVE = Easing.bezier(0.65, 0, 0.35, 1);\nconst SETTLE = Easing.bezier(0.22, 1, 0.36, 1);\n\nconst THEMES = {\n  dark: {\n    bg: \"#0A0A0B\",\n    fg: \"#F3F2EF\",\n    muted: \"#8B8A90\",\n    hair: \"rgba(255,255,255,0.14)\",\n    band: 0.42,\n    rib: \"70\",\n    grain: 0.055,\n  },\n  light: {\n    bg: \"#F5F4F0\",\n    fg: \"#121214\",\n    muted: \"#6E6C67\",\n    hair: \"rgba(18,18,20,0.16)\",\n    band: 0.34,\n    rib: \"60\",\n    grain: 0.04,\n  },\n} as const;\n\n/**\n * gradients.sh \"fluted\" palette, ordered as a heat trail: the freshest stale\n * slices run hot, the oldest cool off to blue.\n */\nconst FLUTED = {\n  dark: [\"#FFE2C4\", \"#FF7A3D\", \"#2F7BFF\", \"#3A47C8\"],\n  light: [\"#FF7A3D\", \"#E2562A\", \"#2F7BFF\", \"#1D2A8F\"],\n} as const;\n\nfunction mix(a: string, b: string, k: number) {\n  const pa = Number.parseInt(a.slice(1), 16);\n  const pb = Number.parseInt(b.slice(1), 16);\n  const ch = (v: number, sh: number) => (v >> sh) & 255;\n  const c = (sh: number) =>\n    Math.round(ch(pa, sh) + (ch(pb, sh) - ch(pa, sh)) * k)\n      .toString(16)\n      .padStart(2, \"0\");\n  return `#${c(16)}${c(8)}${c(0)}`;\n}\n\nfunction sample(stops: readonly string[], k: number) {\n  const x = Math.min(1, Math.max(0, k)) * (stops.length - 1);\n  const i = Math.min(stops.length - 2, Math.floor(x));\n  return mix(stops[i], stops[i + 1], x - i);\n}\n\n/** Advance widths (em) for Bricolage Grotesque 500 at -0.035em tracking. */\nfunction widthEm(text: string) {\n  let w = 0;\n  for (const ch of text) {\n    if (ch === \" \") w += 0.25;\n    else if (\"ijlI.,:;'!|\".includes(ch)) w += 0.26;\n    else if (\"ftr\".includes(ch)) w += 0.38;\n    else if (\"mwMW\".includes(ch)) w += 0.84;\n    else if (ch >= \"A\" && ch <= \"Z\") w += 0.64;\n    else w += 0.56;\n    w -= 0.035;\n  }\n  return Math.max(w, 0.5);\n}\n\nfunction Grain({ opacity }: { opacity: number }) {\n  const id = `g${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  return (\n    <svg\n      width=\"100%\"\n      height=\"100%\"\n      style={{ position: \"absolute\", inset: 0, opacity, pointerEvents: \"none\" }}\n      aria-hidden\n    >\n      <filter id={id}>\n        <feTurbulence\n          type=\"fractalNoise\"\n          baseFrequency=\"0.9\"\n          numOctaves={2}\n          seed={9}\n          stitchTiles=\"stitch\"\n        />\n        <feColorMatrix values=\"0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0.9 0\" />\n      </filter>\n      <rect width=\"100%\" height=\"100%\" filter={`url(#${id})`} />\n    </svg>\n  );\n}\n\nfunction splitTail(word: string) {\n  const m = word.match(/^(.*?)([.!?]+)$/);\n  return m ? [m[1], m[2]] : [word, \"\"];\n}\n\nexport function SlitScan({\n  word = \"Faster.\",\n  caption = \"First render in 1.8s, down from 4.7s\",\n  slices = 120,\n  accent = \"#3B5BFD\",\n  theme = \"dark\",\n  speed = 1,\n}: SlitScanProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H } = useVideoConfig();\n  const u = H / 720;\n  const stage = THEMES[theme];\n  const heat = FLUTED[theme];\n  const n = Math.max(24, Math.round(slices));\n\n  const em = widthEm(word);\n  const size = Math.min(300 * u, (0.74 * W) / em);\n  const wordW = em * size;\n  const travel = W / 2 + wordW / 2 + 40 * u;\n  const lift = -0.06 * H;\n\n  const lag =\n    LAG *\n    interpolate(t, [0, 14], [0.35, 1], CLAMP) *\n    (1 - RESOLVE(interpolate(t, [RESOLVE_AT, RESOLVE_END], [0, 1], CLAMP)));\n  const lagK = lag / LAG;\n  const pos = (tau: number) =>\n    travel * (1 - MOVE(interpolate(tau, [MOVE_AT, MOVE_END], [0, 1], CLAMP)));\n\n  const [body, tail] = splitTail(word);\n  const sw = W / n;\n  const gap = Math.max(0, 1.2 * u * Math.min(1, lagK * 1.6));\n\n  const capIn = SETTLE(\n    interpolate(t, [CAPTION_AT, CAPTION_AT + 20], [0, 1], CLAMP),\n  );\n  const ruleIn = SETTLE(\n    interpolate(t, [CAPTION_AT - 4, CAPTION_AT + 18], [0, 1], CLAMP),\n  );\n  const capSize = Math.max(15, 24 * u);\n  const baseY = H / 2 + lift + size * 0.5;\n\n  // Fluted ground: the same heat palette as a low band behind the word,\n  // ribbed into the slice pitch so the slits read as glass flutes.\n  const bandIn = interpolate(t, [0, 30], [0, 1], CLAMP);\n  const flow = interpolate(t, [0, 125], [-4, 4]);\n  const bandStops = `${heat[3]} ${8 + flow}%, ${heat[2]} ${34 + flow}%, ${heat[1]} ${62 + flow}%, ${heat[0]} ${88 + flow}%`;\n  const ribW = Math.max(4, W / n) * 2;\n\n  const cols = [];\n  const now = pos(t);\n  const stale = 0.015 * size;\n  let prevU = Number.NEGATIVE_INFINITY;\n  for (let i = 0; i < n; i++) {\n    const s = (i + 0.5) / n;\n    const d = lag * s ** 1.8;\n    const left = i * sw;\n    const raw = left - pos(t - d);\n    const u = Math.max(raw, prevU);\n    prevU = u;\n    const x = left - u;\n    const isStale = Math.abs(x - now) > stale;\n    const color = isStale\n      ? sample(heat, (Math.abs(x - now) / (0.75 * wordW)) ** 0.7)\n      : stage.fg;\n    cols.push(\n      <div\n        key={i}\n        style={{\n          position: \"absolute\",\n          left,\n          top: 0,\n          width: sw + 0.75 - (isStale ? gap : 0),\n          height: H,\n          overflow: \"hidden\",\n        }}\n      >\n        <div\n          style={{\n            position: \"absolute\",\n            left: -left,\n            top: 0,\n            width: W,\n            height: H,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            transform: `translate(${x}px, ${lift}px)`,\n          }}\n        >\n          <div\n            style={{\n              fontFamily: bricolage,\n              fontWeight: 500,\n              fontSize: size,\n              letterSpacing: \"-0.035em\",\n              lineHeight: 1,\n              whiteSpace: \"nowrap\",\n              color,\n              paddingBottom: 0.06 * size,\n            }}\n          >\n            {body}\n            {tail ? <span style={{ color: accent }}>{tail}</span> : null}\n          </div>\n        </div>\n      </div>,\n    );\n  }\n\n  return (\n    <AbsoluteFill style={{ background: stage.bg, overflow: \"hidden\" }}>\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          opacity: bandIn * stage.band,\n          backgroundImage: `linear-gradient(90deg, ${bandStops})`,\n          WebkitMaskImage: `radial-gradient(72% 40% at 50% ${50 + (lift / H) * 100 + 4}%, #000 0%, rgba(0,0,0,0.4) 55%, transparent 100%)`,\n          maskImage: `radial-gradient(72% 40% at 50% ${50 + (lift / H) * 100 + 4}%, #000 0%, rgba(0,0,0,0.4) 55%, transparent 100%)`,\n        }}\n      />\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          opacity: bandIn,\n          backgroundImage: `repeating-linear-gradient(90deg, ${stage.bg}00 0px, ${stage.bg}00 ${ribW / 2}px, ${stage.bg}${stage.rib} ${ribW / 2}px, ${stage.bg}${stage.rib} ${ribW}px)`,\n        }}\n      />\n      {cols}\n      <div\n        style={{\n          position: \"absolute\",\n          left: W / 2 - (wordW * 0.5 * ruleIn) / 1,\n          top: baseY + 34 * u,\n          width: wordW * ruleIn,\n          height: Math.max(1, u),\n          background: stage.hair,\n        }}\n      />\n      {caption ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0.08 * W,\n            right: 0.08 * W,\n            top: baseY + 58 * u,\n            textAlign: \"center\",\n            fontFamily: bricolage,\n            fontWeight: 400,\n            fontSize: capSize,\n            letterSpacing: \"-0.005em\",\n            fontVariantNumeric: \"tabular-nums\",\n            color: stage.muted,\n            opacity: capIn,\n            transform: `translateY(${(1 - capIn) * 10 * u}px)`,\n          }}\n        >\n          {caption}\n        </div>\n      ) : null}\n      <Grain opacity={stage.grain} />\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/slit-scan.tsx"
    }
  ],
  "type": "registry:component"
}
