{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bubble-pack",
  "title": "Bubble Pack",
  "description": "Packed bubbles for categories grow around one big gradient bubble reading 420.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/bubble-pack/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/BricolageGrotesque\";\nimport { useId, useMemo } from \"react\";\nimport {\n  AbsoluteFill,\n  Easing,\n  interpolate,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst { fontFamily: sans } = loadFont(\"normal\", {\n  weights: [\"400\", \"500\"],\n  subsets: [\"latin\"],\n});\n\n/** gradients.sh \"swirl\" palette, light core to deep rim. */\nconst SWIRL = [\"#ffe2c4\", \"#ff9a4d\", \"#d2456b\", \"#3a1f6b\"] as const;\n\nfunction mixHex(a: string, b: string, k: number) {\n  const ch = (h: string, o: number) => Number.parseInt(h.slice(o, o + 2), 16);\n  const m = (o: number) => Math.round(ch(a, o) + (ch(b, o) - ch(a, o)) * k);\n  return `rgb(${m(1)}, ${m(3)}, ${m(5)})`;\n}\n\nexport interface BubbleItem {\n  label: string;\n  value: number;\n}\n\nexport interface BubblePackProps {\n  items?: BubbleItem[];\n  highlight?: number;\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\nconst DEFAULT_ITEMS: BubbleItem[] = [\n  { label: \"Launch videos\", value: 420 },\n  { label: \"Demos\", value: 180 },\n  { label: \"Changelogs\", value: 150 },\n  { label: \"Social clips\", value: 130 },\n  { label: \"Onboarding\", value: 96 },\n  { label: \"Ads\", value: 84 },\n  { label: \"Tutorials\", value: 70 },\n  { label: \"Recaps\", value: 58 },\n  { label: \"Teasers\", value: 46 },\n  { label: \"Pitches\", value: 38 },\n];\n\nconst EASE_IO = Easing.bezier(0.65, 0, 0.35, 1);\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_GROW = Easing.bezier(0.45, 0, 0.2, 1);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst THEMES = {\n  dark: {\n    bg: \"#0A0A0B\",\n    fg: \"#F3F2EF\",\n    muted: \"#9A999F\",\n    ramp: [\"#3A3A40\", \"#222226\"],\n    grain: 0.06,\n  },\n  light: {\n    bg: \"#F5F4F0\",\n    fg: \"#121214\",\n    muted: \"#6E6C67\",\n    ramp: [\"#D2D0CA\", \"#E7E5E0\"],\n    grain: 0.045,\n  },\n} as const;\n\nconst GROW_IN = 4;\nconst STAGGER = 3;\nconst ACC_START = 46;\nconst ACC_END = 86;\nconst SUB = 3;\nconst SQUASH = 2.1;\n\ninterface V {\n  x: number;\n  y: number;\n}\n\nfunction tangents(\n  a: V & { r: number },\n  b: V & { r: number },\n  r: number,\n  gap: number,\n) {\n  const da = a.r + r + gap;\n  const db = b.r + r + gap;\n  const dx = b.x - a.x;\n  const dy = b.y - a.y;\n  const d = Math.hypot(dx, dy);\n  if (d > da + db || d < Math.abs(da - db) || d === 0) return [];\n  const along = (da * da - db * db + d * d) / (2 * d);\n  const h = Math.sqrt(Math.max(0, da * da - along * along));\n  const mx = a.x + (dx * along) / d;\n  const my = a.y + (dy * along) / d;\n  return [\n    { x: mx + (h * dy) / d, y: my - (h * dx) / d },\n    { x: mx - (h * dy) / d, y: my + (h * dx) / d },\n  ];\n}\n\nfunction place(placed: (V & { r: number })[], r: number, gap: number): V {\n  if (placed.length === 0) return { x: 0, y: 0 };\n  if (placed.length === 1)\n    return { x: placed[0].x + placed[0].r + r + gap, y: 0 };\n  let best: V = { x: 0, y: 0 };\n  let score = Number.POSITIVE_INFINITY;\n  const o = placed[0];\n  for (const side of [-1, 1]) {\n    const c = { x: o.x + side * (o.r + r + gap), y: o.y };\n    const ok = placed.every(\n      (q) => Math.hypot(q.x - c.x, q.y - c.y) >= q.r + r + gap - 0.01,\n    );\n    const sc = c.x * c.x + 1;\n    if (ok && sc < score) {\n      score = sc;\n      best = c;\n    }\n  }\n  for (let i = 0; i < placed.length; i++) {\n    for (let j = i + 1; j < placed.length; j++) {\n      for (const c of tangents(placed[i], placed[j], r, gap)) {\n        const ok = placed.every(\n          (q) => Math.hypot(q.x - c.x, q.y - c.y) >= q.r + r + gap - 0.01,\n        );\n        if (!ok) continue;\n        const s = c.x * c.x + (c.y * SQUASH) ** 2;\n        if (s < score) {\n          score = s;\n          best = c;\n        }\n      }\n    }\n  }\n  return best;\n}\n\nfunction bounds(cs: (V & { r: number })[]) {\n  let x0 = Number.POSITIVE_INFINITY;\n  let y0 = Number.POSITIVE_INFINITY;\n  let x1 = Number.NEGATIVE_INFINITY;\n  let y1 = Number.NEGATIVE_INFINITY;\n  for (const c of cs) {\n    x0 = Math.min(x0, c.x - c.r);\n    y0 = Math.min(y0, c.y - c.r);\n    x1 = Math.max(x1, c.x + c.r);\n    y1 = Math.max(y1, c.y + c.r);\n  }\n  return { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, w: x1 - x0, h: y1 - y0 };\n}\n\nfunction accentRadius(frame: number, seed: number, full: number) {\n  const k = interpolate(frame, [ACC_START, ACC_END], [0, 1], {\n    ...CLAMP,\n    easing: EASE_GROW,\n  });\n  return seed + (full - seed) * k;\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 }}\n      aria-hidden\n    >\n      <filter id={id}>\n        <feTurbulence\n          type=\"fractalNoise\"\n          baseFrequency=\"0.9\"\n          numOctaves={2}\n          seed={23}\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 formatValue(v: number) {\n  return Math.round(v).toLocaleString(\"en-US\");\n}\n\nexport function BubblePack({\n  items = DEFAULT_ITEMS,\n  highlight = 0,\n  accent = \"#3B5BFD\",\n  theme = \"dark\",\n  speed = 1,\n}: BubblePackProps) {\n  const t = useCurrentFrame() * speed;\n  const { width, height, fps } = useVideoConfig();\n  const u = Math.min(width / 1280, height / 720);\n  const p = THEMES[theme];\n\n  const model = useMemo(() => {\n    const src = items.slice(0, 16).map((it, i) => ({\n      ...it,\n      i,\n      value: Math.max(0, it.value),\n    }));\n    const hlIdx = Math.min(Math.max(0, Math.round(highlight)), src.length - 1);\n    const vmax = Math.max(1, ...src.map((s) => s.value));\n    const Rmax = 172 * u;\n    const gap = 6 * u;\n    const rad = (v: number) => Math.max(10 * u, Rmax * Math.sqrt(v / vmax));\n    const hero = src[hlIdx];\n    const grays = src\n      .filter((s) => s.i !== hlIdx)\n      .sort((a, b) => b.value - a.value)\n      .map((s, rank) => ({ ...s, r: rad(s.value), rank }));\n\n    const seedR = 14 * u;\n    const RA = rad(hero?.value ?? vmax);\n    const S: V = { x: 0, y: 0 };\n    const finals: (V & { r: number })[] = [{ ...S, r: RA }];\n    for (const g of grays) {\n      const c = place(finals, g.r, gap);\n      finals.push({ ...c, r: g.r });\n    }\n\n    const back: V[][] = [];\n    const pos = finals.slice(1).map((c) => ({ x: c.x, y: c.y }));\n    back.push(pos.map((q) => ({ x: q.x, y: q.y })));\n    for (let f = ACC_END - 1; f >= ACC_START; f--) {\n      for (let s = 0; s < SUB; s++) {\n        const ff = f + 1 - (s + 1) / SUB;\n        const ra = accentRadius(ff, seedR, RA);\n        const pull = 0.04;\n        for (const q of pos) {\n          q.x += (S.x - q.x) * pull;\n          q.y += (S.y - q.y) * Math.min(0.5, pull * SQUASH);\n        }\n        for (let it = 0; it < 5; it++) {\n          for (let i = 0; i < pos.length; i++) {\n            const a = pos[i];\n            const ri = grays[i].r;\n            let dx = a.x - S.x;\n            let dy = a.y - S.y;\n            let d = Math.hypot(dx, dy);\n            const minA = ri + ra + gap;\n            if (d < minA) {\n              if (d < 1e-6) {\n                dx = Math.cos(i);\n                dy = Math.sin(i);\n                d = 1;\n              }\n              const o = minA - d;\n              a.x += (dx / d) * o;\n              a.y += (dy / d) * o;\n            }\n            for (let j = i + 1; j < pos.length; j++) {\n              const b = pos[j];\n              const rj = grays[j].r;\n              let ex = b.x - a.x;\n              let ey = b.y - a.y;\n              let e = Math.hypot(ex, ey);\n              const min = ri + rj + gap;\n              if (e >= min) continue;\n              if (e < 1e-6) {\n                ex = Math.cos(i + j);\n                ey = Math.sin(i + j);\n                e = 1;\n              }\n              const o = min - e;\n              const wa = (rj * rj) / (ri * ri + rj * rj);\n              a.x -= (ex / e) * o * wa;\n              a.y -= (ey / e) * o * wa;\n              b.x += (ex / e) * o * (1 - wa);\n              b.y += (ey / e) * o * (1 - wa);\n            }\n          }\n        }\n      }\n      back.push(pos.map((q) => ({ x: q.x, y: q.y })));\n    }\n    const frames = back.reverse();\n    const placed = frames[0].map((q, i) => ({ ...q, r: grays[i].r }));\n    const pb = bounds(placed);\n    const order = placed\n      .map((q, i) => ({ i, d: Math.hypot(q.x - pb.cx, (q.y - pb.cy) * 1.3) }))\n      .sort((a, b) => a.d - b.d);\n    const seq = new Array<number>(placed.length);\n    order.forEach((o, k) => {\n      seq[o.i] = k;\n    });\n\n    const availW = width * 0.84;\n    const availH = height * 0.8;\n    const fit = (cs: (V & { r: number })[]) => {\n      const b = bounds(cs);\n      return {\n        cx: b.cx,\n        cy: b.cy,\n        s: Math.min(1.18, availW / b.w, availH / b.h),\n      };\n    };\n    const camA = fit(placed);\n    const raw = frames.map((fr, k) =>\n      fit([\n        ...fr.map((q, i) => ({ ...q, r: grays[i].r })),\n        { ...S, r: accentRadius(ACC_START + k, seedR, RA) },\n      ]),\n    );\n    const W = 7;\n    const cams = raw.map((_, k) => {\n      let cx = 0;\n      let cy = 0;\n      let sc = 0;\n      let n = 0;\n      for (let j = k - W; j <= k + W; j++) {\n        const c = j < 0 ? camA : raw[Math.min(j, raw.length - 1)];\n        cx += c.cx;\n        cy += c.cy;\n        sc += c.s;\n        n++;\n      }\n      return { cx: cx / n, cy: cy / n, s: Math.min(sc / n, raw[k].s * 1.01) };\n    });\n    return { grays, hero, placed, frames, S, seedR, RA, camA, cams, seq };\n  }, [items, highlight, u, width, height]);\n\n  const { grays, hero, placed, frames, S, seedR, RA, camA, cams, seq } = model;\n\n  const sample = (i: number) => {\n    if (t <= ACC_START) return placed[i];\n    const k = Math.min(t - ACC_START, frames.length - 1);\n    const f0 = Math.floor(k);\n    const f1 = Math.min(f0 + 1, frames.length - 1);\n    const w = k - f0;\n    return {\n      x: frames[f0][i].x * (1 - w) + frames[f1][i].x * w,\n      y: frames[f0][i].y * (1 - w) + frames[f1][i].y * w,\n    };\n  };\n\n  const camAt = (() => {\n    if (t <= ACC_START) return camA;\n    const k = Math.min(t - ACC_START, cams.length - 1);\n    const f0 = Math.floor(k);\n    const f1 = Math.min(f0 + 1, cams.length - 1);\n    const w = k - f0;\n    return {\n      cx: cams[f0].cx * (1 - w) + cams[f1].cx * w,\n      cy: cams[f0].cy * (1 - w) + cams[f1].cy * w,\n      s: cams[f0].s * (1 - w) + cams[f1].s * w,\n    };\n  })();\n  const ccx = camAt.cx;\n  const ccy = camAt.cy;\n  const drift = interpolate(t, [0, ACC_END + 60], [0, 1], {\n    ...CLAMP,\n    easing: EASE_IO,\n  });\n  const cs = camAt.s * (0.985 + 0.015 * drift);\n  const toScreen = (q: V) => ({\n    x: width / 2 + (q.x - ccx) * cs,\n    y: height / 2 + (q.y - ccy) * cs,\n  });\n\n  const ra = accentRadius(t, seedR, RA);\n  const accOpacity = interpolate(\n    t,\n    [ACC_START - 2, ACC_START + 6],\n    [0, 1],\n    CLAMP,\n  );\n  const heroIn = interpolate(t, [ACC_END - 8, ACC_END + 14], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const heroLab = interpolate(t, [ACC_END - 2, ACC_END + 16], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const count = interpolate(t, [ACC_END - 8, ACC_END + 22], [0.5, 1], {\n    ...CLAMP,\n    easing: Easing.bezier(0.22, 1, 0.36, 1),\n  });\n  const quiet = interpolate(t, [ACC_START, ACC_END], [0, 1], {\n    ...CLAMP,\n    easing: EASE_IO,\n  });\n\n  const nG = Math.max(1, grays.length - 1);\n  const shade = (rank: number) => {\n    const k = rank / nG;\n    const a = p.ramp[0];\n    const b = p.ramp[1];\n    const ch = (s: string, o: number) => Number.parseInt(s.slice(o, o + 2), 16);\n    const mix = (o: number) => Math.round(ch(a, o) + (ch(b, o) - ch(a, o)) * k);\n    return `rgb(${mix(1)}, ${mix(3)}, ${mix(5)})`;\n  };\n\n  const gid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const sc = toScreen(S);\n  const heroR = ra * cs;\n  const heroFont = Math.min(136 * u, heroR * 0.68);\n\n  return (\n    <AbsoluteFill style={{ background: p.bg, overflow: \"hidden\" }}>\n      <svg\n        width={width}\n        height={height}\n        style={{ position: \"absolute\", inset: 0 }}\n        aria-hidden\n      >\n        {grays.map((g, i) => {\n          const start = GROW_IN + seq[i] * STAGGER;\n          const k = spring({\n            frame: t - start,\n            fps,\n            config: { damping: 15, stiffness: 120, mass: 0.7 },\n          });\n          const o = interpolate(t, [start, start + 6], [0, 1], CLAMP);\n          const q = toScreen(sample(i));\n          return (\n            <circle\n              key={g.label + g.i}\n              cx={q.x}\n              cy={q.y}\n              r={Math.max(0, g.r * cs * (0.6 + 0.4 * k))}\n              fill={shade(g.rank)}\n              opacity={o}\n            />\n          );\n        })}\n        <defs>\n          <linearGradient\n            id={`${gid}h`}\n            x1={0.15}\n            y1={0}\n            x2={0.85}\n            y2={1}\n            gradientTransform={`rotate(${-10 + 14 * drift} 0.5 0.5)`}\n          >\n            <stop offset={0} stopColor={SWIRL[0]} />\n            <stop offset={0.22} stopColor={SWIRL[1]} />\n            <stop offset={0.58} stopColor={SWIRL[2]} />\n            <stop offset={1} stopColor={mixHex(SWIRL[3], accent, 0.25)} />\n          </linearGradient>\n        </defs>\n        {hero ? (\n          <circle\n            cx={sc.x}\n            cy={sc.y}\n            r={heroR}\n            fill={`url(#${gid}h)`}\n            opacity={accOpacity}\n          />\n        ) : null}\n      </svg>\n\n      {grays.map((g, i) => {\n        const rs = g.r * cs;\n        const nameSize = 16 * u;\n        const fits =\n          rs >= 44 * u && g.label.length * nameSize * 0.56 <= rs * 1.6;\n        if (!fits) return null;\n        const start = GROW_IN + seq[i] * STAGGER + 8;\n        const a = interpolate(t, [start, start + 12], [0, 1], {\n          ...CLAMP,\n          easing: EASE_OUT,\n        });\n        const q = toScreen(sample(i));\n        return (\n          <div\n            key={g.label + g.i}\n            style={{\n              position: \"absolute\",\n              left: q.x,\n              top: q.y,\n              transform: `translate(-50%, -50%) translateY(${(1 - a) * 5 * u}px)`,\n              display: \"flex\",\n              flexDirection: \"column\",\n              alignItems: \"center\",\n              gap: 2 * u,\n              opacity: a * (1 - 0.25 * quiet),\n              whiteSpace: \"nowrap\",\n            }}\n          >\n            <span\n              style={{\n                fontFamily: sans,\n                fontWeight: 400,\n                fontSize: nameSize,\n                letterSpacing: \"-0.01em\",\n                color: p.fg,\n              }}\n            >\n              {g.label}\n            </span>\n            <span\n              style={{\n                fontFamily: sans,\n                fontWeight: 500,\n                fontSize: 15 * u,\n                color: p.muted,\n                fontVariantNumeric: \"tabular-nums\",\n              }}\n            >\n              {formatValue(g.value)}\n            </span>\n          </div>\n        );\n      })}\n\n      {hero ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: sc.x,\n            top: sc.y,\n            transform: \"translate(-50%, -50%)\",\n            display: \"flex\",\n            flexDirection: \"column\",\n            alignItems: \"center\",\n            whiteSpace: \"nowrap\",\n          }}\n        >\n          <span\n            style={{\n              fontFamily: sans,\n              fontWeight: 500,\n              fontSize: heroFont,\n              lineHeight: 1,\n              letterSpacing: \"-0.04em\",\n              color: \"#FFFFFF\",\n              fontVariantNumeric: \"tabular-nums\",\n              opacity: heroIn,\n              filter:\n                heroIn < 0.99 ? `blur(${(1 - heroIn) * 8 * u}px)` : undefined,\n              transform: `translateY(${(1 - heroIn) * 16 * u}px)`,\n            }}\n          >\n            {formatValue(hero.value * count)}\n          </span>\n          <span\n            style={{\n              marginTop: 8 * u,\n              fontFamily: sans,\n              fontWeight: 400,\n              fontSize: 25 * u,\n              letterSpacing: \"-0.01em\",\n              color: \"rgba(255,255,255,0.92)\",\n              opacity: heroLab,\n              transform: `translateY(${(1 - heroLab) * 8 * u}px)`,\n            }}\n          >\n            {hero.label}\n          </span>\n        </div>\n      ) : null}\n\n      <AbsoluteFill\n        style={{\n          background: `radial-gradient(ellipse 85% 85% at 50% 50%, transparent 64%, ${p.bg} 100%)`,\n          opacity: 0.5,\n        }}\n      />\n      <Grain opacity={p.grain} />\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/bubble-pack.tsx"
    }
  ],
  "type": "registry:component"
}
