{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "weight-wave",
  "title": "Weight Wave",
  "description": "A wave of font weight travels across a line of text and settles on a highlighted word.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/weight-wave/index.tsx",
      "content": "\"use client\";\n\nimport { loadVariableFont } from \"@remotion/google-fonts/Geist\";\nimport type React from \"react\";\nimport { useId, useMemo } from \"react\";\nimport {\n  AbsoluteFill,\n  Easing,\n  interpolate,\n  interpolateColors,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst { fontFamily: sans } = loadVariableFont(\"normal\", {\n  subsets: [\"latin\"],\n});\n\nexport interface WeightWaveProps {\n  text?: string;\n  highlight?: string;\n  from?: number;\n  peak?: number;\n  settle?: number;\n  waves?: number;\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\n/** Geist 500 advance widths (per 1000 em) for ASCII 32–126 (widest weight used). */\nconst GEIST_500 = [\n  243, 228, 361, 515, 643, 810, 649, 186, 290, 290, 427, 562, 213, 418, 213,\n  494, 673, 406, 630, 625, 629, 641, 604, 531, 624, 606, 302, 302, 546, 544,\n  546, 570, 925, 689, 688, 713, 701, 609, 595, 713, 716, 280, 607, 656, 583,\n  890, 745, 751, 657, 745, 680, 654, 568, 694, 688, 968, 633, 594, 561, 361,\n  470, 361, 438, 558, 258, 565, 608, 563, 608, 576, 412, 607, 591, 256, 284,\n  609, 282, 885, 591, 588, 608, 608, 394, 537, 410, 586, 560, 829, 607, 553,\n  552, 395, 274, 395, 523,\n];\n/** The wght axis is kept inside this range (brand rule: display type at most 500). */\nconst WGHT_MIN = 100;\nconst WGHT_MAX = 500;\nconst TRACK = -0.035;\n\nconst ENTER = 4;\nconst WAVE_START = 16;\nconst WAVE_LEN = 46;\nconst WAVE_GAP = 24;\nconst SETTLE_STAGGER = 2.4;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_TRAVEL = Easing.bezier(0.42, 0, 0.58, 1);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\n// gradients.sh \"beam\": the settled hero word takes a clip-text beam fill, and a faint\n// beam haze sits low behind the line.\nconst THEMES = {\n  dark: {\n    bg: \"#02030A\",\n    fg: \"#F2F3F8\",\n    fgClear: \"rgba(242,243,248,0)\",\n    grain: 0.07,\n    beam: [\"#F4F7FF\", \"#8FB4FF\", \"#3D5BFF\"],\n    haze: \"rgba(61,91,255,0.16)\",\n  },\n  light: {\n    bg: \"#F4F5F8\",\n    fg: \"#12131A\",\n    fgClear: \"rgba(18,19,26,0)\",\n    grain: 0.045,\n    beam: [\"#3D5BFF\", \"#2A3BD0\", \"#1A1660\"],\n    haze: \"rgba(143,180,255,0.24)\",\n  },\n} as const;\n\nfunction em(text: string) {\n  let w = 0;\n  for (const ch of text) {\n    const code = ch.charCodeAt(0);\n    w += (code >= 32 && code < 127 ? GEIST_500[code - 32] : 600) / 1000;\n    w += TRACK;\n  }\n  return w;\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={41}\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\nexport function WeightWave({\n  text = \"Made to move.\",\n  highlight,\n  from = 100,\n  peak = 500,\n  settle = 460,\n  waves = 2,\n  accent = \"#3B5BFD\",\n  theme = \"dark\",\n  speed = 1,\n}: WeightWaveProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H, fps } = useVideoConfig();\n  const c = THEMES[theme];\n\n  const chars = useMemo(() => Array.from(text), [text]);\n  const n = chars.length;\n\n  const accentRange = useMemo(() => {\n    const target = highlight ?? text.trim().split(/\\s+/).pop() ?? \"\";\n    const at = target ? text.lastIndexOf(target) : -1;\n    return at < 0 ? [n, n] : [at, at + Array.from(target).length];\n  }, [highlight, text, n]);\n\n  const F = Math.min(H * 0.32, (W * 0.8) / em(text));\n  const clampW = (w: number) => Math.min(WGHT_MAX, Math.max(WGHT_MIN, w));\n  const lo = clampW(from);\n  const hi = clampW(peak);\n  const rest = clampW(settle);\n\n  const settleStart = WAVE_START + (waves - 1) * WAVE_GAP + WAVE_LEN - 8;\n  const sigma = 0.12;\n  const push = interpolate(t, [0, settleStart + 70], [0, 1], CLAMP);\n\n  const tintAt = settleStart + 12;\n  const sheen = interpolate(t, [tintAt, tintAt + 80], [0, 1], CLAMP);\n  // The deep end of the beam is the brand accent (dark), or its first stop (light).\n  const beam =\n    theme === \"light\"\n      ? [accent, c.beam[1], c.beam[2]]\n      : [c.beam[0], c.beam[1], accent];\n  const beamFill = `linear-gradient(${96 + sheen * 10}deg, ${beam[0]} ${-10 + sheen * 8}%, ${beam[1]} ${44 + sheen * 6}%, ${beam[2]} 100%)`;\n  const hazeIn = interpolate(t, [WAVE_START, settleStart + 40], [0.35, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const group = (nodes: React.ReactNode[]) => {\n    const [a, b] = accentRange;\n    if (a >= b) return nodes;\n    return [\n      ...nodes.slice(0, a),\n      <span\n        key=\"accent\"\n        style={{\n          display: \"inline-flex\",\n          alignItems: \"baseline\",\n          padding: \"0.12em 0.06em 0.22em\",\n          margin: \"-0.12em -0.06em -0.22em\",\n          color: \"transparent\",\n          backgroundImage: t >= tintAt ? beamFill : \"none\",\n          WebkitBackgroundClip: \"text\",\n          backgroundClip: \"text\",\n        }}\n      >\n        {nodes.slice(a, b)}\n      </span>,\n      ...nodes.slice(b),\n    ];\n  };\n\n  return (\n    <AbsoluteFill style={{ background: c.bg, overflow: \"hidden\" }}>\n      <AbsoluteFill\n        style={{\n          background: `radial-gradient(48% 34% at 50% 60%, ${c.haze} 0%, transparent 72%)`,\n          opacity: hazeIn,\n        }}\n      />\n      <AbsoluteFill\n        style={{\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          transform: `scale(${1 + push * 0.025})`,\n          transformOrigin: \"50% 50%\",\n        }}\n      >\n        <div\n          style={{\n            display: \"flex\",\n            alignItems: \"baseline\",\n            justifyContent: \"center\",\n            whiteSpace: \"pre\",\n            fontFamily: sans,\n            fontSize: F,\n            lineHeight: 1,\n            letterSpacing: `${TRACK}em`,\n            fontKerning: \"normal\",\n          }}\n        >\n          {group(\n            chars.map((ch, i) => {\n              const p = n > 1 ? i / (n - 1) : 0.5;\n              let bump = 0;\n              for (let k = 0; k < waves; k++) {\n                const s = WAVE_START + k * WAVE_GAP;\n                const crest = interpolate(t, [s, s + WAVE_LEN], [-0.3, 1.3], {\n                  ...CLAMP,\n                  easing: EASE_TRAVEL,\n                });\n                const live = t > s && t < s + WAVE_LEN ? 1 : 0;\n                bump = Math.max(\n                  bump,\n                  live * Math.exp(-(((p - crest) / sigma) ** 2)),\n                );\n              }\n              const wave = lo + (hi - lo) * bump;\n              const sp = spring({\n                frame: t - settleStart - i * SETTLE_STAGGER,\n                fps,\n                config: { damping: 15, stiffness: 120, mass: 0.9 },\n              });\n              const weight = clampW(wave + (rest - wave) * sp);\n              const isAccent = i >= accentRange[0] && i < accentRange[1];\n              const tint = isAccent\n                ? interpolate(\n                    t,\n                    [\n                      settleStart + 14 + (i - accentRange[0]) * 2,\n                      settleStart + 30 + (i - accentRange[0]) * 2,\n                    ],\n                    [0, 1],\n                    CLAMP,\n                  )\n                : 0;\n              const o = interpolate(t, [ENTER + i, ENTER + i + 12], [0, 1], {\n                ...CLAMP,\n                easing: EASE_OUT,\n              });\n              return (\n                <span\n                  key={`${i}-${ch}`}\n                  style={{\n                    display: \"inline-block\",\n                    fontWeight: weight,\n                    color: interpolateColors(tint, [0, 1], [c.fg, c.fgClear]),\n                    opacity: o,\n                  }}\n                >\n                  {ch}\n                </span>\n              );\n            }),\n          )}\n        </div>\n      </AbsoluteFill>\n      <Grain opacity={c.grain} />\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/weight-wave.tsx"
    }
  ],
  "type": "registry:component"
}
