Chart Bar Grouped
Original · freegrouped bar chart with legend
byWeb InnoventixReact + Tailwind
chartbargroupedcharts
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-bar-grouped.jsonchart-bar-grouped.tsx
"use client";
import { useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Region = "americas" | "emea" | "apac";
type Datum = { quarter: string } & Record<Region, number>;
type SeriesDef = {
key: Region;
label: string;
fill: string;
dot: string;
};
const DATA: Datum[] = [
{ quarter: "Q1", americas: 42, emea: 31, apac: 18 },
{ quarter: "Q2", americas: 48, emea: 34, apac: 22 },
{ quarter: "Q3", americas: 51, emea: 38, apac: 27 },
{ quarter: "Q4", americas: 59, emea: 41, apac: 33 },
];
const SERIES: SeriesDef[] = [
{ key: "americas", label: "Americas", fill: "fill-indigo-500 dark:fill-indigo-400", dot: "bg-indigo-500 dark:bg-indigo-400" },
{ key: "emea", label: "EMEA", fill: "fill-emerald-500 dark:fill-emerald-400", dot: "bg-emerald-500 dark:bg-emerald-400" },
{ key: "apac", label: "APAC", fill: "fill-amber-500 dark:fill-amber-400", dot: "bg-amber-500 dark:bg-amber-400" },
];
const W = 720;
const H = 380;
const PLOT = { x: 52, y: 28, w: 648, h: 288 };
const BASE = PLOT.y + PLOT.h;
const NICE_MAX = 60;
const TICKS = [0, 15, 30, 45, 60];
const BAND = PLOT.w / DATA.length;
type Bar = {
gi: number;
key: Region;
label: string;
fill: string;
value: number;
x: number;
y: number;
w: number;
h: number;
cx: number;
};
type Active = { gi: number; key: Region } | null;
function tickY(v: number) {
return PLOT.y + PLOT.h - (v / NICE_MAX) * PLOT.h;
}
export default function ChartBarGrouped() {
const reduce = useReducedMotion();
const [hidden, setHidden] = useState<Set<Region>>(() => new Set());
const [active, setActive] = useState<Active>(null);
const [showValues, setShowValues] = useState(false);
const bars = useMemo<Bar[]>(() => {
const visible = SERIES.filter((s) => !hidden.has(s.key));
const count = visible.length;
const gap = 6;
const inner = BAND * 0.62;
const out: Bar[] = [];
DATA.forEach((d, gi) => {
const bandX = PLOT.x + gi * BAND;
const start = bandX + (BAND - inner) / 2;
const bw = count > 0 ? (inner - gap * (count - 1)) / count : 0;
visible.forEach((s, i) => {
const value = d[s.key];
const barH = (value / NICE_MAX) * PLOT.h;
const x = start + i * (bw + gap);
out.push({
gi,
key: s.key,
label: s.label,
fill: s.fill,
value,
x,
y: BASE - barH,
w: bw,
h: barH,
cx: x + bw / 2,
});
});
});
return out;
}, [hidden]);
const activeBar = active ? bars.find((b) => b.gi === active.gi && b.key === active.key) : undefined;
const toggle = (key: Region) => {
setActive(null);
setHidden((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
if (SERIES.length - next.size <= 1) return prev;
next.add(key);
}
return next;
});
};
const totals = SERIES.map((s) => ({
key: s.key,
total: DATA.reduce((sum, d) => sum + d[s.key], 0),
}));
const transition = { duration: reduce ? 0 : 0.5, ease: "easeOut" as const };
const tipW = 118;
const tipH = 46;
let tipX = 0;
let tipY = 0;
let tipBelow = false;
if (activeBar) {
tipX = Math.max(4, Math.min(W - tipW - 4, activeBar.cx - tipW / 2));
tipY = activeBar.y - tipH - 10;
if (tipY < 2) {
tipY = activeBar.y + activeBar.h + 10;
tipBelow = true;
}
}
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:py-20 dark:bg-slate-950">
<style>{`
@keyframes chartbg-pop {
from { opacity: 0; }
to { opacity: 1; }
}
.chartbg-tip { animation: chartbg-pop .18s ease-out both; }
.chartbg-bar { outline: none; cursor: pointer; }
.chartbg-bar:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px; }
@media (prefers-color-scheme: dark) {
.chartbg-bar:focus-visible { outline-color: #a5b4fc; }
}
@media (prefers-reduced-motion: reduce) {
.chartbg-tip { animation: none; }
}
`}</style>
<div className="mx-auto max-w-3xl">
<figure className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 dark:border-slate-800 dark:bg-slate-900">
<figcaption className="mb-5 flex flex-col gap-1">
<h2 id="chartbg-title" className="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">
Cloud revenue by region
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Annual recurring revenue, FY2025 ($M), grouped by fiscal quarter
</p>
</figcaption>
<div className="mb-4 flex flex-wrap items-center gap-2">
{SERIES.map((s) => {
const isVisible = !hidden.has(s.key);
const isLast = SERIES.length - hidden.size <= 1 && isVisible;
const total = totals.find((t) => t.key === s.key)?.total ?? 0;
return (
<button
key={s.key}
type="button"
aria-pressed={isVisible}
disabled={isLast}
onClick={() => toggle(s.key)}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70 dark:border-slate-700 dark:text-slate-200 dark:ring-offset-slate-900 dark:hover:bg-slate-800"
>
<span className={`h-3 w-3 rounded-[3px] ${s.dot} ${isVisible ? "" : "opacity-30"}`} aria-hidden="true" />
<span className={isVisible ? "" : "text-slate-400 line-through dark:text-slate-500"}>{s.label}</span>
<span className="tabular-nums text-xs text-slate-400 dark:text-slate-500">${total}M</span>
</button>
);
})}
<label className="ml-auto inline-flex cursor-pointer items-center gap-2 rounded-full px-2 py-1.5 text-sm font-medium text-slate-600 focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 dark:text-slate-300 dark:ring-offset-slate-900">
<input
type="checkbox"
checked={showValues}
onChange={(e) => setShowValues(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 accent-indigo-600 focus:outline-none dark:border-slate-600"
/>
Show values
</label>
</div>
<div className="w-full overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
role="group"
aria-labelledby="chartbg-title"
className="min-w-[520px] select-none"
>
{TICKS.map((t) => (
<g key={t}>
<line
x1={PLOT.x}
x2={PLOT.x + PLOT.w}
y1={tickY(t)}
y2={tickY(t)}
className={t === 0 ? "stroke-slate-300 dark:stroke-slate-700" : "stroke-slate-200 dark:stroke-slate-800"}
strokeWidth={1}
/>
<text
x={PLOT.x - 10}
y={tickY(t) + 4}
textAnchor="end"
className="fill-slate-400 text-[12px] tabular-nums dark:fill-slate-500"
>
{t}
</text>
</g>
))}
<text
x={PLOT.x - 10}
y={PLOT.y - 12}
textAnchor="end"
className="fill-slate-400 text-[11px] font-medium dark:fill-slate-500"
>
$M
</text>
{DATA.map((d, gi) => (
<text
key={d.quarter}
x={PLOT.x + gi * BAND + BAND / 2}
y={BASE + 26}
textAnchor="middle"
className="fill-slate-600 text-[13px] font-medium dark:fill-slate-300"
>
{d.quarter}
</text>
))}
<AnimatePresence>
{bars.map((b) => {
const dim = activeBar !== undefined && !(activeBar.gi === b.gi && activeBar.key === b.key);
return (
<motion.rect
key={`${b.gi}-${b.key}`}
className={`chartbg-bar ${b.fill}`}
rx={3}
role="img"
tabIndex={0}
aria-label={`${DATA[b.gi].quarter}, ${b.label}: $${b.value} million`}
initial={reduce ? false : { x: b.x, width: b.w, y: BASE, height: 0, fillOpacity: 1 }}
animate={{ x: b.x, width: b.w, y: b.y, height: b.h, fillOpacity: dim ? 0.3 : 1 }}
exit={{ height: 0, y: BASE, opacity: 0, pointerEvents: "none" }}
transition={transition}
onMouseEnter={() => setActive({ gi: b.gi, key: b.key })}
onMouseLeave={() => setActive(null)}
onFocus={() => setActive({ gi: b.gi, key: b.key })}
onBlur={() => setActive(null)}
/>
);
})}
</AnimatePresence>
{showValues &&
bars.map((b) => (
<text
key={`v-${b.gi}-${b.key}`}
x={b.cx}
y={b.y - 6}
textAnchor="middle"
className="fill-slate-500 text-[11px] font-semibold tabular-nums dark:fill-slate-400"
>
{b.value}
</text>
))}
{activeBar && (
<rect
x={activeBar.x - 2}
y={activeBar.y - 2}
width={activeBar.w + 4}
height={activeBar.h + 4}
rx={4}
fill="none"
strokeWidth={2}
className="stroke-slate-900 dark:stroke-white"
pointerEvents="none"
/>
)}
{activeBar && (
<g className="chartbg-tip" pointerEvents="none">
<rect
x={tipX}
y={tipY}
width={tipW}
height={tipH}
rx={8}
className="fill-slate-900 dark:fill-white"
/>
<text
x={tipX + tipW / 2}
y={tipY + 18}
textAnchor="middle"
className="fill-slate-300 text-[11px] dark:fill-slate-600"
>
{DATA[activeBar.gi].quarter} · {activeBar.label}
</text>
<text
x={tipX + tipW / 2}
y={tipY + 36}
textAnchor="middle"
className="fill-white text-[14px] font-semibold tabular-nums dark:fill-slate-900"
>
${activeBar.value}M ARR
</text>
{!tipBelow && (
<path
d={`M ${activeBar.cx - 6} ${tipY + tipH} L ${activeBar.cx + 6} ${tipY + tipH} L ${activeBar.cx} ${tipY + tipH + 7} Z`}
className="fill-slate-900 dark:fill-white"
/>
)}
</g>
)}
</svg>
</div>
<p className="mt-4 text-xs text-slate-400 dark:text-slate-500">
Toggle a region in the legend to isolate its trend. Hover or focus a bar for exact figures.
</p>
<table className="sr-only">
<caption>Cloud annual recurring revenue by region and fiscal quarter, FY2025, in millions of dollars.</caption>
<thead>
<tr>
<th scope="col">Quarter</th>
{SERIES.map((s) => (
<th key={s.key} scope="col">
{s.label}
</th>
))}
</tr>
</thead>
<tbody>
{DATA.map((d) => (
<tr key={d.quarter}>
<th scope="row">{d.quarter}</th>
{SERIES.map((s) => (
<td key={s.key}>${d[s.key]}M</td>
))}
</tr>
))}
</tbody>
</table>
</figure>
</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 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

Chart Bubble
OriginalSVG bubble/scatter chart

