{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spec-sweep",
  "title": "Spec Sweep",
  "description": "A one-line spec slides in word by word while a highlight runs along it and an icon charges up",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/spec-sweep/index.tsx",
      "content": "// Spec Sweep · snapcn — https://snapcn.dev/docs/text/spec-sweep\n\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { useEffect, useId, useMemo, 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  mixOklch,\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n} from \"@/lib/snap-cn-ui\";\nimport { BOLT, ENTRY, ICON_BOX, SWEEP } from \"./data\";\n\n/**\n * Spec Sweep — one centred line that arrives word by word and then gets read\n * out by a highlight. Every word slides in on its own clock, a soft band of\n * brightness travels along the line, and an icon charges up from the bottom.\n *\n * The defaults are measured off a 1920x1080 reference: it is a single curve\n * through time (`SWEEP`), reached at each point of the line by its distance from\n * the start plus that word's entry delay, so copy of any length rides the same\n * band.\n */\n\nconst { fontFamily: INTER } = loadFont(\"normal\", {\n  weights: [\"500\"],\n  subsets: [\"latin\"],\n});\n\nexport type SpecSegmentRole = \"lead\" | \"icon\" | \"accent\" | \"muted\";\n\nexport interface SpecSegment {\n  /** The words. Split on spaces; each word enters on its own. Unused by `icon`. */\n  text?: string;\n  /**\n   * `lead` rides the highlight and falls back to grey behind it. `accent` stays\n   * lit once the highlight has reached it. `muted` never rises past `mutedLevel`.\n   * `icon` draws the `icon` path, charged with `accentColor`.\n   */\n  role?: SpecSegmentRole;\n}\n\nexport interface SpecSweepProps {\n  segments?: SpecSegment[];\n  /** SVG path data of the icon, drawn in an `iconBox` viewBox. */\n  icon?: string;\n  /** viewBox width and height of `icon`. */\n  iconBox?: [number, number];\n  /** Icon height, in em. */\n  iconSize?: number;\n  /** Icon colour once charged. Defaults to `theme.primary`. */\n  accentColor?: string;\n  /** Opacity of the icon before the charge reaches it. */\n  iconDim?: number;\n  /** Lighter inner edge of the icon, px on a 1920 stage. 0 turns it off. */\n  iconRim?: number;\n  /** Colour of that edge. Defaults to `accentColor` towards white. */\n  iconRimColor?: string;\n  /** Frames from the icon's entry to the start of its charge, and the charge's length. */\n  chargeDelay?: number;\n  chargeFrames?: number;\n  /** A family from `fonts.ts`, or a CSS family you have loaded yourself. */\n  fontFamily?: string;\n  /** Type size, px on a 1920 stage. Long copy shrinks from here to fit. */\n  fontSize?: number;\n  fontWeight?: number;\n  /** How far a word slides in, em. */\n  travel?: number;\n  /** Frames between one word's entry and the next. Compressed on long copy. */\n  stagger?: number;\n  /** Frames the highlight takes to cross the line. */\n  sweepFrames?: number;\n  /** Level `muted` words are capped at. */\n  mutedLevel?: number;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  speed?: number;\n}\n\n/** Widest the line may get, as a fraction of the stage: a word still sliding in stays on it. */\nconst MAX_LINE = 0.85;\n/** The last word has landed by this frame, however long the copy. */\nconst LAST_ENTRY = 14;\n/** Frame the highlight's centre leaves the start of the line. */\nconst SWEEP_START = 1.476;\n/** Frames a word is ahead of the highlight at the very start of its slide. */\nconst SWEEP_LEAD = 6.093;\n/** The text baseline sits this far below the stage centre, em. */\nconst BASELINE = 0.258;\n/** Spacing of the sampled gradient stops, px on a 1920 stage. */\nconst STOP_EVERY = 8;\n\nconst chargeEase = Easing.bezier(0.166, 0.092, 0.279, 1);\n\nconst held = { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" } as const;\nconst ENTRY_AT = ENTRY.map((_, i) => i);\nconst SWEEP_AT = SWEEP.map(([at]) => at);\nconst SWEEP_LEVEL = SWEEP.map(([, level]) => level);\n\n/** The fraction of `travel` a word has left, `age` frames after it entered. */\nexport const entryOffset = (age: number) =>\n  interpolate(age, ENTRY_AT, [...ENTRY], held);\n\n/** Level of a word at `t` frames past the highlight's centre. */\nexport const sweepLevel = (\n  role: SpecSegmentRole,\n  t: number,\n  mutedLevel = 0.6,\n) => {\n  if (role === \"accent\" && t > 0) return 1;\n  const level = interpolate(t, SWEEP_AT, SWEEP_LEVEL, held);\n  return role === \"muted\" ? Math.min(level, mutedLevel) : level;\n};\n\nexport interface LineItem {\n  role: SpecSegmentRole;\n  text: string;\n  /** Left edge from the start of the line, and advance. px. */\n  x: number;\n  w: number;\n}\n\n/** Splits segments into words and lays them on one line. */\nexport const layoutLine = (\n  segments: SpecSegment[],\n  measure: (text: string) => number,\n  iconWidth: number,\n) => {\n  const space = measure(\" \");\n  const items: LineItem[] = [];\n  let x = 0;\n  const push = (role: SpecSegmentRole, text: string, w: number) => {\n    items.push({ role, text, x, w });\n    x += w + space;\n  };\n  for (const s of segments) {\n    if (s.role === \"icon\") push(\"icon\", \"\", iconWidth);\n    else\n      for (const word of (s.text ?? \"\").split(/\\s+/).filter(Boolean))\n        push(s.role ?? \"lead\", word, measure(word));\n  }\n  return { items, width: Math.max(0, x - space) };\n};\n\nconst makeMeasure = (font: string, size: number) => {\n  const ctx =\n    typeof document === \"undefined\"\n      ? null\n      : document.createElement(\"canvas\").getContext(\"2d\");\n  if (!ctx) return (text: string) => text.length * 0.55 * size;\n  ctx.font = font;\n  return (text: string) => ctx.measureText(text).width;\n};\n\nconst DEFAULT_SEGMENTS: SpecSegment[] = [\n  { text: \"Ship\", role: \"lead\" },\n  { role: \"icon\" },\n  { text: \"a launch film\", role: \"accent\" },\n  { text: \"in\", role: \"muted\" },\n  { text: \"20 minutes\", role: \"accent\" },\n];\n\nexport const SpecSweep = ({\n  segments = DEFAULT_SEGMENTS,\n  icon = BOLT,\n  iconBox = ICON_BOX,\n  iconSize = 1.452,\n  accentColor,\n  iconDim = 0.39,\n  iconRim = 1.47,\n  iconRimColor,\n  chargeDelay = 11.47,\n  chargeFrames = 30,\n  fontFamily,\n  fontSize = 85.15,\n  fontWeight = 500,\n  travel = 1.466,\n  stagger = 2,\n  sweepFrames = 54,\n  mutedLevel = 0.6,\n  theme,\n  mode = \"dark\",\n  speed = 1,\n}: SpecSweepProps) => {\n  const f = useCurrentFrame() * speed;\n  const { width, height } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const id = useId().replace(/:/g, \"\");\n  const family = resolveFont(fontFamily ?? t.fontFamily) ?? INTER;\n  const stage = Math.min(width / 1920, height / 1080);\n\n  // A width measured before the face lands is a width for the fallback, so the\n  // layout waits on it; a render holds its first frame until that has re-rendered.\n  const [fontTick, setFontTick] = useState(0);\n  const [handle] = useState(() =>\n    getRemotionEnvironment().isRendering\n      ? delayRender(\"spec-sweep: loading the face\")\n      : null,\n  );\n  const fontSpec = `${fontWeight} 100px ${family}`;\n  useEffect(() => {\n    let alive = true;\n    document.fonts\n      .load(fontSpec)\n      .catch(() => [])\n      .then(() => {\n        if (alive) setFontTick((n) => n + 1);\n      });\n    return () => {\n      alive = false;\n    };\n  }, [fontSpec]);\n  useEffect(() => {\n    if (fontTick > 0 && handle != null) continueRender(handle);\n  }, [fontTick, handle]);\n\n  const line = useMemo(() => {\n    void fontTick;\n    const place = (size: number) =>\n      layoutLine(\n        segments,\n        makeMeasure(`${fontWeight} ${size}px ${family}`, size),\n        (size * iconSize * iconBox[0]) / iconBox[1],\n      );\n    const size = fontSize * stage;\n    const first = place(size);\n    const fit = Math.min(1, (MAX_LINE * width) / Math.max(1, first.width));\n    const fitted = fit < 1 ? place(size * fit) : first;\n    return { ...fitted, size: size * fit, left: (width - fitted.width) / 2 };\n  }, [\n    fontTick,\n    segments,\n    fontWeight,\n    family,\n    iconSize,\n    iconBox,\n    fontSize,\n    stage,\n    width,\n  ]);\n\n  const { items, size, left } = line;\n  const step = Math.min(stagger, LAST_ENTRY / Math.max(1, items.length - 1));\n  const rate = line.width / sweepFrames; // px per frame\n  const baseline = height / 2 + BASELINE * size;\n  const accent = accentColor ?? t.primary;\n  const rimColor = iconRimColor ?? mixOklch(accent, \"#ffffff\", 0.3);\n  const ink = t.foreground;\n\n  const iconH = size * iconSize;\n  const iconScale = iconH / iconBox[1];\n\n  return (\n    <AbsoluteFill style={{ background: t.background }}>\n      <svg\n        width={width}\n        height={height}\n        style={{ position: \"absolute\", inset: 0, overflow: \"hidden\" }}\n      >\n        {items.map((item, i) => {\n          const age = f - step * i;\n          if (age < 0) return null;\n          const off = entryOffset(age);\n          const x = left + item.x + off * travel * size;\n\n          if (item.role === \"icon\") {\n            const charge = chargeEase(\n              Math.min(1, Math.max(0, (age - chargeDelay) / chargeFrames)),\n            );\n            const level = iconBox[1] * (1.006 - 1.018 * charge);\n            const clip = `${id}c${i}`;\n            const shape = (fill: string, rim: string) => (\n              <>\n                <path d={icon} fill={fill} />\n                {iconRim > 0 && (\n                  <path\n                    d={icon}\n                    fill=\"none\"\n                    stroke={rim}\n                    strokeWidth={(2 * iconRim * stage) / iconScale}\n                    clipPath={`url(#${clip})`}\n                  />\n                )}\n              </>\n            );\n            return (\n              <g\n                key={`${item.x}`}\n                transform={`translate(${x} ${height / 2 - iconH / 2}) scale(${iconScale})`}\n              >\n                <clipPath id={clip}>\n                  <path d={icon} />\n                </clipPath>\n                <clipPath id={`${clip}a`}>\n                  <rect x={-9e3} y={-9e3} width={18e3} height={9e3 + level} />\n                </clipPath>\n                <clipPath id={`${clip}b`}>\n                  <rect x={-9e3} y={level} width={18e3} height={9e3} />\n                </clipPath>\n                <g clipPath={`url(#${clip}a)`} opacity={iconDim}>\n                  {shape(accent, rimColor)}\n                </g>\n                <g clipPath={`url(#${clip}b)`}>{shape(accent, rimColor)}</g>\n              </g>\n            );\n          }\n\n          // The highlight reaches each point of the word at its own moment, so the\n          // word is painted with a gradient sampled from that one curve.\n          const grad = `${id}g${i}`;\n          const from = age + SWEEP_LEAD * off - SWEEP_START - item.x / rate;\n          const n = Math.max(2, Math.ceil(item.w / (STOP_EVERY * stage)));\n          const stops = Array.from({ length: n + 1 }, (_, k) => {\n            const u = k / n;\n            return (\n              <stop\n                key={u}\n                offset={u}\n                stopColor={ink}\n                stopOpacity={sweepLevel(\n                  item.role,\n                  from - (u * item.w) / rate,\n                  mutedLevel,\n                )}\n              />\n            );\n          });\n          return (\n            <g key={`${item.x}`}>\n              <linearGradient\n                id={grad}\n                gradientUnits=\"userSpaceOnUse\"\n                x1={x}\n                x2={x + item.w}\n                y1={0}\n                y2={0}\n              >\n                {stops}\n              </linearGradient>\n              <text\n                x={x}\n                y={baseline}\n                fill={`url(#${grad})`}\n                style={{\n                  fontFamily: family,\n                  fontWeight,\n                  fontSize: size,\n                  textRendering: \"geometricPrecision\",\n                }}\n              >\n                {item.text}\n              </text>\n            </g>\n          );\n        })}\n      </svg>\n    </AbsoluteFill>\n  );\n};\n\nexport default SpecSweep;\n",
      "type": "registry:component",
      "target": "components/snap-cn/spec-sweep/index.tsx"
    },
    {
      "path": "registry/snap-cn/spec-sweep/data.ts",
      "content": "// Spec Sweep · snapcn — https://snapcn.dev/docs/text/spec-sweep\n/**\n * Everything below is measured frame by frame off a 1920x1080, 30fps reference.\n * Times are frames, lengths are em unless a comment says otherwise.\n */\n\n/** A word's slide in, as a fraction of `travel`, one entry per frame of its own clock. */\nexport const ENTRY = [\n  1, 0.64, 0.484, 0.374, 0.29, 0.224, 0.172, 0.128, 0.094, 0.066, 0.044, 0.028,\n  0.016, 0.008, 0.002, 0,\n] as const;\n\n/**\n * Brightness against time, relative to the moment the highlight's centre reaches\n * a point of the line: [frames, level], where 1 is full ink. One curve explains\n * every word of the reference to 0.8% of full scale.\n */\nexport const SWEEP: readonly (readonly [number, number])[] = [\n  [-50, 0.255],\n  [-49, 0.271],\n  [-45, 0.377],\n  [-42, 0.437],\n  [-39, 0.467],\n  [-31, 0.525],\n  [-25, 0.587],\n  [-12, 0.806],\n  [-5, 0.966],\n  [-3, 0.99],\n  [0, 1],\n  [4, 0.995],\n  [6, 0.974],\n  [13, 0.81],\n  [26, 0.588],\n  [33, 0.517],\n  [43, 0.462],\n  [51, 0.365],\n];\n\n/** Default mark: a rounded bolt in a `ICON_BOX` viewBox. */\nexport const BOLT =\n  \"M54.13 2.15A5.78 5.78 0 0 1 64.00 7.96L47.41 48.65A1.03 1.03 0 0 0 48.36 50.07L78.20 50.31A4.41 4.41 0 0 1 81.63 57.46L30.83 121.75A5.39 5.39 0 0 1 21.55 116.54L37.50 73.40L6.96 72.69A4.58 4.58 0 0 1 3.50 65.25Z\";\nexport const ICON_BOX: [number, number] = [83.07, 123.79];\n",
      "type": "registry:component",
      "target": "components/snap-cn/spec-sweep/data.ts"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/text/spec-sweep?ref=cli — 75 frames at 30fps. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
