Chart Bubble
Original · freeSVG bubble/scatter chart
byWeb InnoventixReact + Tailwind
chartbubblecharts
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.jsonchart-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 · Enter pins it · arrow
keys move between channels · 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 quoteSimilar components
Browse all →
Chart Bar
Originalhand-built SVG/div bar chart with axes

Chart Bar Grouped
Originalgrouped bar chart with legend

Chart Line
OriginalSVG line chart with points and grid

Chart Area
OriginalSVG area chart with gradient fill

Chart Donut
OriginalSVG donut chart with centre label

Chart Pie
OriginalSVG pie chart with legend

Chart Radial
Originalradial progress bars chart

Chart Sparkline
Originalinline sparklines in stat rows

Chart Gauge
Originalsemicircle gauge chart

Chart Stacked Bar
Originalstacked bar chart

Chart Progress
Originalhorizontal progress/metric chart

Chart Heatmap
Originalcontribution-style heatmap grid

