Web InnoventixFreeCode

Chart Bubble

Original · free

SVG bubble/scatter chart

byWeb InnoventixReact + Tailwind
chartbubblecharts
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/chart-bubble.json
chart-bubble.tsx
"use client"

import { useMemo, useRef, useState, type KeyboardEvent } from "react"
import { motion, useReducedMotion } from "motion/react"

type CategoryKey = "Inbound" | "Owned" | "Paid" | "Partner"

type Channel = {
  name: string
  category: CategoryKey
  cpa: number
  conv: number
  users: number
}

const CHANNELS: Channel[] = [
  { name: "Organic Search", category: "Inbound", cpa: 18, conv: 4.8, users: 42000 },
  { name: "Content Marketing", category: "Inbound", cpa: 24, conv: 3.9, users: 28000 },
  { name: "Email", category: "Owned", cpa: 12, conv: 6.2, users: 19000 },
  { name: "Referral", category: "Owned", cpa: 15, conv: 5.1, users: 9500 },
  { name: "Direct", category: "Owned", cpa: 8, conv: 7.4, users: 22000 },
  { name: "Paid Search", category: "Paid", cpa: 46, conv: 3.2, users: 33000 },
  { name: "Social Ads", category: "Paid", cpa: 58, conv: 2.1, users: 51000 },
  { name: "Affiliate", category: "Partner", cpa: 38, conv: 2.8, users: 14000 },
]

const CATEGORY_ORDER: CategoryKey[] = ["Inbound", "Owned", "Paid", "Partner"]

const CATEGORY_META: Record<
  CategoryKey,
  { dot: string; bubble: string; ring: string }
> = {
  Inbound: {
    dot: "bg-emerald-500",
    bubble: "fill-emerald-500 stroke-emerald-600 dark:fill-emerald-400 dark:stroke-emerald-300",
    ring: "stroke-emerald-500 dark:stroke-emerald-300",
  },
  Owned: {
    dot: "bg-indigo-500",
    bubble: "fill-indigo-500 stroke-indigo-600 dark:fill-indigo-400 dark:stroke-indigo-300",
    ring: "stroke-indigo-500 dark:stroke-indigo-300",
  },
  Paid: {
    dot: "bg-amber-500",
    bubble: "fill-amber-500 stroke-amber-600 dark:fill-amber-400 dark:stroke-amber-300",
    ring: "stroke-amber-500 dark:stroke-amber-300",
  },
  Partner: {
    dot: "bg-violet-500",
    bubble: "fill-violet-500 stroke-violet-600 dark:fill-violet-400 dark:stroke-violet-300",
    ring: "stroke-violet-500 dark:stroke-violet-300",
  },
}

const VIEW_W = 760
const VIEW_H = 460
const ML = 66
const MR = 30
const MT = 30
const MB = 56
const IN_W = VIEW_W - ML - MR
const IN_H = VIEW_H - MT - MB
const X_MAX = 64
const Y_MAX = 8

const X_TICKS = [0, 16, 32, 48, 64]
const Y_TICKS = [0, 2, 4, 6, 8]

const sx = (v: number): number => ML + (v / X_MAX) * IN_W
const sy = (v: number): number => MT + IN_H - (v / Y_MAX) * IN_H

const fmtUsers = (u: number): string =>
  `${(u / 1000).toFixed(u % 1000 === 0 ? 0 : 1)}K`

export default function ChartBubble() {
  const reduce = useReducedMotion()

  const [activeCats, setActiveCats] = useState<Record<CategoryKey, boolean>>({
    Inbound: true,
    Owned: true,
    Paid: true,
    Partner: true,
  })
  const [hoverIndex, setHoverIndex] = useState<number | null>(null)
  const [focusIndex, setFocusIndex] = useState<number | null>(null)
  const [pinnedIndex, setPinnedIndex] = useState<number | null>(null)

  const bubbleRefs = useRef<Array<SVGGElement | null>>([])

  const radiusOf = useMemo(() => {
    const all = CHANNELS.map((c) => c.users)
    const lo = Math.sqrt(Math.min(...all))
    const hi = Math.sqrt(Math.max(...all))
    const span = hi - lo || 1
    return (u: number): number => 12 + ((Math.sqrt(u) - lo) / span) * 24
  }, [])

  const visibleIndices = CHANNELS.map((_, i) => i).filter(
    (i) => activeCats[CHANNELS[i].category]
  )

  const activeIndex =
    hoverIndex !== null ? hoverIndex : focusIndex !== null ? focusIndex : pinnedIndex

  function moveFocus(from: number, dir: number) {
    const pos = visibleIndices.indexOf(from)
    if (pos === -1 || visibleIndices.length === 0) return
    const target =
      visibleIndices[(pos + dir + visibleIndices.length) % visibleIndices.length]
    bubbleRefs.current[target]?.focus()
  }

  function handleKeyDown(e: KeyboardEvent<SVGGElement>, i: number) {
    if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
      e.preventDefault()
      setPinnedIndex((p) => (p === i ? null : i))
    } else if (e.key === "Escape") {
      setPinnedIndex(null)
    } else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault()
      moveFocus(i, 1)
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault()
      moveFocus(i, -1)
    }
  }

  function toggleCat(cat: CategoryKey) {
    const turningOff = activeCats[cat]
    setActiveCats((prev) => ({ ...prev, [cat]: !prev[cat] }))
    if (!turningOff) return
    if (hoverIndex !== null && CHANNELS[hoverIndex].category === cat) setHoverIndex(null)
    if (focusIndex !== null && CHANNELS[focusIndex].category === cat) setFocusIndex(null)
    if (pinnedIndex !== null && CHANNELS[pinnedIndex].category === cat) setPinnedIndex(null)
  }

  const tip = activeIndex !== null ? CHANNELS[activeIndex] : null
  const tipX = tip ? sx(tip.cpa) : 0
  const tipY = tip ? sy(tip.conv) : 0
  const tipBelow = tipY < 132

  return (
    <section className="relative w-full bg-gradient-to-b from-slate-50 via-white to-slate-50 px-4 py-16 sm:py-24 dark:from-zinc-950 dark:via-zinc-950 dark:to-black">
      <style>{`
        @keyframes chartbubble-pulse {
          0%, 100% { stroke-opacity: 0.35; }
          50% { stroke-opacity: 0.95; }
        }
        @keyframes chartbubble-tip-in {
          from { opacity: 0; transform: translateY(4px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .chartbubble-pinned-ring { animation: chartbubble-pulse 1.9s ease-in-out infinite; }
        .chartbubble-tip-card { animation: chartbubble-tip-in 140ms ease-out both; }
        .chartbubble-hit { outline: none; }
        .chartbubble-hit:focus-visible {
          outline: 2px solid #6366f1;
          outline-offset: 3px;
          border-radius: 9999px;
        }
        @media (prefers-reduced-motion: reduce) {
          .chartbubble-pinned-ring,
          .chartbubble-tip-card { animation: none; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-8 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
          <header className="max-w-2xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-widest text-slate-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
              <svg viewBox="0 0 16 16" aria-hidden="true" className="h-3 w-3 fill-none stroke-current" strokeWidth="1.6">
                <circle cx="5" cy="10" r="3" />
                <circle cx="11" cy="5.5" r="2" />
              </svg>
              Acquisition analytics
            </span>
            <h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
              Channel efficiency map
            </h2>
            <p className="mt-2 text-sm leading-relaxed text-slate-600 sm:text-base dark:text-zinc-400">
              Eight acquisition channels plotted by what each signup costs against how
              well it converts. Bubble area scales with monthly active users, so the
              cheap-but-tiny channels stop looking like the whole story.
            </p>
          </header>

          <div className="mt-6 flex flex-wrap items-center gap-2" role="group" aria-label="Filter channels by category">
            {CATEGORY_ORDER.map((cat) => {
              const on = activeCats[cat]
              return (
                <button
                  key={cat}
                  type="button"
                  aria-pressed={on}
                  onClick={() => toggleCat(cat)}
                  className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
                    on
                      ? "border-slate-300 bg-slate-50 text-slate-700 hover:bg-slate-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
                      : "border-slate-200 bg-transparent text-slate-400 hover:border-slate-300 dark:border-zinc-800 dark:text-zinc-600 dark:hover:border-zinc-700"
                  }`}
                >
                  <span
                    aria-hidden="true"
                    className={`h-2.5 w-2.5 rounded-full ${on ? CATEGORY_META[cat].dot : "bg-slate-300 dark:bg-zinc-700"}`}
                  />
                  <span className={on ? "" : "line-through"}>{cat}</span>
                </button>
              )
            })}
            <span
              role="status"
              aria-live="polite"
              className="ml-auto text-xs text-slate-400 dark:text-zinc-500"
            >
              {visibleIndices.length} of {CHANNELS.length} channels shown
            </span>
          </div>

          <div className="relative mt-4">
            <svg
              viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
              className="block h-auto w-full select-none overflow-visible"
              role="group"
              aria-label="Bubble chart of acquisition channels: cost per acquisition on the horizontal axis, conversion rate on the vertical axis, bubble size by monthly active users."
            >
              <rect
                x={ML}
                y={MT}
                width={(24 / X_MAX) * IN_W}
                height={IN_H / 2}
                rx={10}
                className="fill-emerald-500 stroke-emerald-500"
                fillOpacity={0.06}
                strokeOpacity={0.28}
                strokeDasharray="4 4"
              />
              <text
                x={ML + 12}
                y={MT + 20}
                className="fill-emerald-600 dark:fill-emerald-400"
                fontSize="11"
                fontWeight="600"
                letterSpacing="0.06em"
              >
                HIGH EFFICIENCY
              </text>

              {X_TICKS.map((t) => (
                <line
                  key={`gx-${t}`}
                  x1={sx(t)}
                  y1={MT}
                  x2={sx(t)}
                  y2={MT + IN_H}
                  className="stroke-slate-200 dark:stroke-zinc-800"
                  strokeWidth={1}
                />
              ))}
              {Y_TICKS.map((t) => (
                <line
                  key={`gy-${t}`}
                  x1={ML}
                  y1={sy(t)}
                  x2={ML + IN_W}
                  y2={sy(t)}
                  className="stroke-slate-200 dark:stroke-zinc-800"
                  strokeWidth={1}
                />
              ))}

              <line
                x1={ML}
                y1={MT + IN_H}
                x2={ML + IN_W}
                y2={MT + IN_H}
                className="stroke-slate-300 dark:stroke-zinc-700"
                strokeWidth={1.5}
              />
              <line
                x1={ML}
                y1={MT}
                x2={ML}
                y2={MT + IN_H}
                className="stroke-slate-300 dark:stroke-zinc-700"
                strokeWidth={1.5}
              />

              {X_TICKS.map((t) => (
                <text
                  key={`tx-${t}`}
                  x={sx(t)}
                  y={MT + IN_H + 20}
                  textAnchor="middle"
                  fontSize="11"
                  className="fill-slate-500 dark:fill-zinc-500"
                >
                  {`$${t}`}
                </text>
              ))}
              {Y_TICKS.map((t) => (
                <text
                  key={`ty-${t}`}
                  x={ML - 12}
                  y={sy(t) + 4}
                  textAnchor="end"
                  fontSize="11"
                  className="fill-slate-500 dark:fill-zinc-500"
                >
                  {`${t}%`}
                </text>
              ))}

              <text
                x={ML + IN_W / 2}
                y={VIEW_H - 10}
                textAnchor="middle"
                fontSize="12"
                fontWeight="600"
                className="fill-slate-600 dark:fill-zinc-400"
              >
                Cost per acquisition (USD)
              </text>
              <text
                transform={`translate(18 ${MT + IN_H / 2}) rotate(-90)`}
                textAnchor="middle"
                fontSize="12"
                fontWeight="600"
                className="fill-slate-600 dark:fill-zinc-400"
              >
                Conversion rate
              </text>

              {CHANNELS.map((c, i) => {
                if (!activeCats[c.category]) return null
                const meta = CATEGORY_META[c.category]
                const cx = sx(c.cpa)
                const cy = sy(c.conv)
                const r = radiusOf(c.users)
                const isActive = activeIndex === i
                const isPinned = pinnedIndex === i
                return (
                  <motion.g
                    key={c.name}
                    ref={(el: SVGGElement | null) => {
                      bubbleRefs.current[i] = el
                    }}
                    className="chartbubble-hit"
                    tabIndex={0}
                    role="button"
                    aria-pressed={isPinned}
                    aria-label={`${c.name}, ${c.category} channel. Cost per acquisition ${c.cpa} dollars. Conversion rate ${c.conv} percent. ${c.users.toLocaleString("en-US")} monthly active users.`}
                    onMouseEnter={() => setHoverIndex(i)}
                    onMouseLeave={() => setHoverIndex((h) => (h === i ? null : h))}
                    onFocus={() => setFocusIndex(i)}
                    onBlur={() => setFocusIndex((f) => (f === i ? null : f))}
                    onClick={() => setPinnedIndex((p) => (p === i ? null : i))}
                    onKeyDown={(e) => handleKeyDown(e, i)}
                    initial={reduce ? false : { opacity: 0, scale: 0 }}
                    animate={{ opacity: 1, scale: 1 }}
                    transition={
                      reduce
                        ? { duration: 0 }
                        : { delay: 0.05 * i, type: "spring", stiffness: 240, damping: 20 }
                    }
                    style={{
                      transformBox: "fill-box",
                      transformOrigin: "center",
                      cursor: "pointer",
                    }}
                  >
                    {(isActive || isPinned) && (
                      <circle
                        cx={cx}
                        cy={cy}
                        r={r + 6}
                        fill="none"
                        strokeWidth={2}
                        strokeOpacity={isPinned ? 0.9 : 0.55}
                        className={`${meta.ring}${isPinned ? " chartbubble-pinned-ring" : ""}`}
                      />
                    )}
                    <circle
                      cx={cx}
                      cy={cy}
                      r={r}
                      fillOpacity={isActive ? 0.82 : 0.5}
                      strokeWidth={isActive ? 2.5 : 1.25}
                      className={`${meta.bubble} transition-all duration-150`}
                    />
                    <text
                      x={cx}
                      y={cy + 4}
                      textAnchor="middle"
                      fontSize="11"
                      fontWeight="700"
                      className="pointer-events-none fill-white dark:fill-zinc-950"
                      fillOpacity={r > 17 ? 0.95 : 0}
                    >
                      {c.name.charAt(0)}
                    </text>
                  </motion.g>
                )
              })}
            </svg>

            {tip && (
              <div
                className="pointer-events-none absolute z-10 w-max max-w-[240px]"
                style={{
                  left: `${(tipX / VIEW_W) * 100}%`,
                  top: `${(tipY / VIEW_H) * 100}%`,
                  transform: `translate(-50%, ${tipBelow ? "16px" : "calc(-100% - 16px)"})`,
                }}
              >
                <div className="chartbubble-tip-card relative rounded-xl border border-slate-200 bg-white/95 px-3.5 py-3 shadow-lg shadow-slate-900/10 backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/95 dark:shadow-black/50">
                  <span
                    aria-hidden="true"
                    className={`absolute left-1/2 h-2.5 w-2.5 -translate-x-1/2 rotate-45 border-slate-200 bg-white dark:border-zinc-700 dark:bg-zinc-900 ${
                      tipBelow ? "-top-[6px] border-l border-t" : "-bottom-[6px] border-b border-r"
                    }`}
                  />
                  <div className="flex items-center gap-2">
                    <span
                      aria-hidden="true"
                      className={`h-2.5 w-2.5 shrink-0 rounded-full ${CATEGORY_META[tip.category].dot}`}
                    />
                    <span className="text-sm font-semibold text-slate-900 dark:text-white">
                      {tip.name}
                    </span>
                  </div>
                  <div className="mt-0.5 text-[10px] font-semibold uppercase tracking-widest text-slate-400 dark:text-zinc-500">
                    {tip.category} channel
                  </div>
                  <dl className="mt-2.5 grid grid-cols-3 gap-x-4">
                    {[
                      { k: "CPA", v: `$${tip.cpa}` },
                      { k: "Conv.", v: `${tip.conv}%` },
                      { k: "MAU", v: fmtUsers(tip.users) },
                    ].map((s) => (
                      <div key={s.k}>
                        <dt className="text-[10px] font-medium uppercase tracking-wide text-slate-400 dark:text-zinc-500">
                          {s.k}
                        </dt>
                        <dd className="mt-0.5 text-sm font-semibold tabular-nums text-slate-800 dark:text-zinc-100">
                          {s.v}
                        </dd>
                      </div>
                    ))}
                  </dl>
                </div>
              </div>
            )}
          </div>

          <table className="sr-only">
            <caption>
              Acquisition channels by cost per acquisition, conversion rate, and monthly
              active users
            </caption>
            <thead>
              <tr>
                <th scope="col">Channel</th>
                <th scope="col">Category</th>
                <th scope="col">Cost per acquisition (USD)</th>
                <th scope="col">Conversion rate (%)</th>
                <th scope="col">Monthly active users</th>
              </tr>
            </thead>
            <tbody>
              {CHANNELS.map((c) => (
                <tr key={c.name}>
                  <th scope="row">{c.name}</th>
                  <td>{c.category}</td>
                  <td>{c.cpa}</td>
                  <td>{c.conv}</td>
                  <td>{c.users.toLocaleString("en-US")}</td>
                </tr>
              ))}
            </tbody>
          </table>

          <footer className="mt-5 flex flex-col gap-2 border-t border-slate-200 pt-4 text-xs text-slate-500 sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800 dark:text-zinc-500">
            <p>
              Bubble size scales with monthly active users. Blended six-month
              cohort, self-serve plans only.
            </p>
            <p className="text-slate-400 dark:text-zinc-600">
              Hover or focus a bubble for detail &middot; Enter pins it &middot; arrow
              keys move between channels &middot; Esc unpins
            </p>
          </footer>
        </div>
      </div>
    </section>
  )
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →