Chart Stacked Bar
Original · freestacked bar chart
byWeb InnoventixReact + Tailwind
chartstackedbarcharts
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-stacked-bar.jsonchart-stacked-bar.tsx
"use client";
import { useId, useMemo, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type SeriesDef = {
key: string;
label: string;
short: string;
bar: string;
dot: string;
};
const SERIES = [
{
key: "subscriptions",
label: "Subscriptions",
short: "Subs",
bar: "bg-indigo-500 dark:bg-indigo-400",
dot: "bg-indigo-500 dark:bg-indigo-400",
},
{
key: "usage",
label: "Usage-based",
short: "Usage",
bar: "bg-sky-500 dark:bg-sky-400",
dot: "bg-sky-500 dark:bg-sky-400",
},
{
key: "services",
label: "Onboarding & services",
short: "Services",
bar: "bg-emerald-500 dark:bg-emerald-400",
dot: "bg-emerald-500 dark:bg-emerald-400",
},
{
key: "addons",
label: "Add-ons",
short: "Add-ons",
bar: "bg-amber-500 dark:bg-amber-400",
dot: "bg-amber-500 dark:bg-amber-400",
},
] as const satisfies readonly SeriesDef[];
type SeriesKey = (typeof SERIES)[number]["key"];
type MonthRow = { month: string } & Record<SeriesKey, number>;
type Mode = "abs" | "share";
const DATA: MonthRow[] = [
{ month: "Jan", subscriptions: 82, usage: 24, services: 18, addons: 9 },
{ month: "Feb", subscriptions: 88, usage: 27, services: 16, addons: 11 },
{ month: "Mar", subscriptions: 95, usage: 31, services: 21, addons: 12 },
{ month: "Apr", subscriptions: 103, usage: 34, services: 19, addons: 14 },
{ month: "May", subscriptions: 110, usage: 39, services: 23, addons: 15 },
{ month: "Jun", subscriptions: 121, usage: 44, services: 20, addons: 17 },
];
const CHART_H = 300;
const TICKS = 5;
function niceCeil(v: number): number {
if (v <= 0) return 10;
const pow = Math.pow(10, Math.floor(Math.log10(v)));
const n = v / pow;
let m: number;
if (n <= 1) m = 1;
else if (n <= 2) m = 2;
else if (n <= 2.5) m = 2.5;
else if (n <= 5) m = 5;
else m = 10;
return m * pow;
}
function fmtMoney(v: number): string {
if (v >= 1000) return `$${(v / 1000).toFixed(2)}M`;
return `$${v}k`;
}
type Seg = {
key: SeriesKey;
label: string;
bar: string;
value: number;
share: number;
px: number;
};
type Column = {
mi: number;
month: string;
colSum: number;
columnPx: number;
segs: Seg[];
};
export default function StackedBarChart() {
const reduced = useReducedMotion();
const uid = useId();
const [visible, setVisible] = useState<Record<SeriesKey, boolean>>({
subscriptions: true,
usage: true,
services: true,
addons: true,
});
const [mode, setMode] = useState<Mode>("abs");
const [highlighted, setHighlighted] = useState<SeriesKey | null>(null);
const [active, setActive] = useState<{ mi: number; key: SeriesKey } | null>(
null,
);
const visibleDefs = SERIES.filter((s) => visible[s.key]);
const topKey = visibleDefs.length
? visibleDefs[visibleDefs.length - 1].key
: null;
const view = useMemo(() => {
let maxColSum = 0;
for (const row of DATA) {
let s = 0;
for (const def of SERIES) if (visible[def.key]) s += row[def.key];
if (s > maxColSum) maxColSum = s;
}
const axisMax = niceCeil(maxColSum);
const factorAbs = CHART_H / axisMax;
const columns: Column[] = DATA.map((row, mi) => {
let colSum = 0;
for (const def of SERIES) if (visible[def.key]) colSum += row[def.key];
const segs: Seg[] = SERIES.filter((def) => visible[def.key]).map(
(def) => {
const value = row[def.key];
const share = colSum > 0 ? (value / colSum) * 100 : 0;
const rawPx =
mode === "abs"
? value * factorAbs
: colSum > 0
? (value / colSum) * CHART_H
: 0;
const px = value > 0 ? Math.max(rawPx, 3) : 0;
return { key: def.key, label: def.label, bar: def.bar, value, share, px };
},
);
const columnPx =
mode === "abs" ? colSum * factorAbs : colSum > 0 ? CHART_H : 0;
return { mi, month: row.month, colSum, columnPx, segs };
});
const seriesTotals = {} as Record<SeriesKey, number>;
for (const def of SERIES) {
let t = 0;
for (const row of DATA) t += row[def.key];
seriesTotals[def.key] = t;
}
let leadKey: SeriesKey | null = null;
let leadVal = -1;
for (const def of SERIES) {
if (visible[def.key] && seriesTotals[def.key] > leadVal) {
leadVal = seriesTotals[def.key];
leadKey = def.key;
}
}
const totalVisible = visibleDefs.reduce(
(a, def) => a + seriesTotals[def.key],
0,
);
return { axisMax, columns, seriesTotals, leadKey, leadVal, totalVisible };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, mode]);
const ticks = Array.from({ length: TICKS + 1 }, (_, i) => i / TICKS);
const leadDef = view.leadKey
? SERIES.find((s) => s.key === view.leadKey)
: null;
const ease: [number, number, number, number] = [0.22, 0.61, 0.36, 1];
const transition = reduced ? { duration: 0 } : { duration: 0.55, ease };
function toggleSeries(key: SeriesKey) {
setVisible((v) => ({ ...v, [key]: !v[key] }));
setHighlighted((h) => (h === key ? null : h));
}
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 lg:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes sbcRise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
@keyframes sbcPop { from { opacity: 0; transform: translate(-50%, 6px) scale(0.96); } to { opacity: 1; transform: translate(-50%, 0) scale(1); } }
.sbc-anim { animation: sbcRise 0.6s cubic-bezier(0.22,0.61,0.36,1) both; }
.sbc-pop { animation: sbcPop 0.18s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.sbc-anim, .sbc-pop { animation: none !important; }
}
`}</style>
<div
className="sbc-anim mx-auto w-full max-w-4xl rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-900/5 sm:p-8 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40"
>
{/* Header */}
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-md">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Revenue analytics
</p>
<h2 className="mt-2 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Recurring revenue by stream
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Monthly breakdown across the first half of 2026, in thousands.
Toggle a stream, hover a segment, or switch to share view to
compare the mix.
</p>
</div>
{/* Mode toggle */}
<fieldset className="shrink-0">
<legend className="sr-only">Chart value mode</legend>
<div className="inline-flex rounded-xl border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800">
{([
{ id: "abs", label: "Absolute" },
{ id: "share", label: "Share %" },
] as const).map((opt) => {
const checked = mode === opt.id;
return (
<label
key={opt.id}
className={`relative cursor-pointer rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-slate-900 has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-slate-100 dark:has-[:focus-visible]:ring-white dark:has-[:focus-visible]:ring-offset-slate-800 ${
checked
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-slate-50"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
<input
type="radio"
name={`${uid}-mode`}
value={opt.id}
checked={checked}
onChange={() => setMode(opt.id)}
className="sr-only"
/>
<span className="pointer-events-none">{opt.label}</span>
</label>
);
})}
</div>
</fieldset>
</div>
{/* Stat row */}
<dl className="mt-6 grid grid-cols-3 gap-3">
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/40">
<dt className="text-[0.7rem] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
H1 total shown
</dt>
<dd className="mt-0.5 text-lg font-bold tabular-nums text-slate-900 dark:text-slate-50">
{fmtMoney(view.totalVisible)}
</dd>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/40">
<dt className="text-[0.7rem] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Leading stream
</dt>
<dd className="mt-0.5 flex items-center gap-1.5 text-lg font-bold text-slate-900 dark:text-slate-50">
{leadDef ? (
<>
<span
className={`h-2.5 w-2.5 shrink-0 rounded-full ${leadDef.dot}`}
aria-hidden="true"
/>
<span className="truncate text-base">{leadDef.short}</span>
</>
) : (
<span className="text-base text-slate-400">—</span>
)}
</dd>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/40">
<dt className="text-[0.7rem] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Streams shown
</dt>
<dd className="mt-0.5 text-lg font-bold tabular-nums text-slate-900 dark:text-slate-50">
{visibleDefs.length}
<span className="text-sm font-normal text-slate-400">
{" "}
/ {SERIES.length}
</span>
</dd>
</div>
</dl>
{/* Chart */}
<figure
className="mt-8"
aria-labelledby={`${uid}-cap`}
>
<div className="flex gap-3">
{/* Y axis */}
<div
className="relative w-10 shrink-0 sm:w-12"
style={{ height: CHART_H }}
aria-hidden="true"
>
{ticks.map((frac) => {
const label =
mode === "abs"
? fmtMoney(Math.round(view.axisMax * frac))
: `${Math.round(frac * 100)}%`;
return (
<span
key={frac}
className="absolute right-0 translate-y-1/2 text-[0.65rem] font-medium tabular-nums text-slate-400 dark:text-slate-500"
style={{ bottom: frac * CHART_H }}
>
{label}
</span>
);
})}
</div>
{/* Plot */}
<div className="min-w-0 flex-1">
<div className="relative" style={{ height: CHART_H }}>
{/* Gridlines */}
{ticks.map((frac) => (
<div
key={frac}
aria-hidden="true"
className={`absolute inset-x-0 border-t ${
frac === 0
? "border-slate-300 dark:border-slate-600"
: "border-slate-200/80 dark:border-slate-700/50"
}`}
style={{ bottom: frac * CHART_H }}
/>
))}
{/* Columns */}
<div className="absolute inset-0 flex items-end gap-2 sm:gap-3">
{view.columns.map((col) => (
<div
key={col.month}
className="relative flex h-full min-w-0 flex-1 flex-col justify-end"
>
{/* Tooltip */}
{active?.mi === col.mi &&
(() => {
const seg = col.segs.find(
(s) => s.key === active.key,
);
if (!seg) return null;
return (
<div
aria-hidden="true"
className="sbc-pop pointer-events-none absolute left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-center shadow-lg dark:border-slate-700 dark:bg-slate-950"
style={{ bottom: col.columnPx + 12 }}
>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-900 dark:text-slate-50">
<span
className={`h-2 w-2 rounded-full ${seg.bar}`}
aria-hidden="true"
/>
{seg.label}
</div>
<div className="mt-0.5 text-xs tabular-nums text-slate-500 dark:text-slate-400">
{fmtMoney(seg.value)} ·{" "}
{Math.round(seg.share)}% of {col.month}
</div>
</div>
);
})()}
{/* Bar stack */}
<div className="flex w-full flex-col-reverse">
{col.segs.map((seg) => {
const dim =
highlighted !== null && highlighted !== seg.key;
const isTop = seg.key === topKey;
return (
<motion.button
key={seg.key}
type="button"
aria-pressed={highlighted === seg.key}
aria-label={`${col.month}, ${seg.label}: ${fmtMoney(
seg.value,
)}, ${Math.round(seg.share)} percent of ${
col.month
}. Activate to emphasize this stream.`}
onMouseEnter={() =>
setActive({ mi: col.mi, key: seg.key })
}
onMouseLeave={() => setActive(null)}
onFocus={() =>
setActive({ mi: col.mi, key: seg.key })
}
onBlur={() => setActive(null)}
onClick={() =>
setHighlighted((h) =>
h === seg.key ? null : seg.key,
)
}
initial={{ height: 0 }}
animate={{ height: seg.px, opacity: dim ? 0.35 : 1 }}
transition={transition}
className={`block w-full cursor-pointer border-t border-black/10 outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-white/10 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900 ${
seg.bar
} ${isTop ? "rounded-t-md" : ""} ${
highlighted === seg.key
? "ring-2 ring-inset ring-white/50"
: ""
}`}
/>
);
})}
</div>
</div>
))}
</div>
{/* Empty state */}
{visibleDefs.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="rounded-lg bg-slate-100 px-4 py-2 text-sm text-slate-500 dark:bg-slate-800 dark:text-slate-400">
No streams selected — enable one below.
</p>
</div>
)}
</div>
{/* X axis labels */}
<div className="mt-3 flex gap-2 sm:gap-3">
{view.columns.map((col) => (
<div
key={col.month}
className="min-w-0 flex-1 text-center text-xs font-medium text-slate-500 dark:text-slate-400"
>
<span className="block">{col.month}</span>
<span className="mt-0.5 block text-[0.7rem] font-semibold tabular-nums text-slate-700 dark:text-slate-300">
{mode === "abs"
? fmtMoney(col.colSum)
: col.colSum > 0
? "100%"
: "—"}
</span>
</div>
))}
</div>
</div>
</div>
<figcaption id={`${uid}-cap`} className="sr-only">
Stacked bar chart of monthly recurring revenue by stream for the
first half of 2026. Streams: Subscriptions, Usage-based, Onboarding
and services, and Add-ons.
</figcaption>
</figure>
{/* Legend */}
<div className="mt-6 flex flex-wrap items-center gap-2 border-t border-slate-200 pt-5 dark:border-slate-800">
<span className="mr-1 text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
Streams
</span>
{SERIES.map((def) => {
const on = visible[def.key];
return (
<button
key={def.key}
type="button"
aria-pressed={on}
onClick={() => toggleSeries(def.key)}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900 ${
on
? "border-slate-300 bg-white text-slate-800 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
: "border-slate-200 bg-transparent text-slate-400 hover:text-slate-600 dark:border-slate-800 dark:text-slate-500 dark:hover:text-slate-300"
}`}
>
<span
className={`h-2.5 w-2.5 rounded-full transition-opacity ${def.dot} ${
on ? "opacity-100" : "opacity-30"
}`}
aria-hidden="true"
/>
<span className={on ? "" : "line-through decoration-slate-400"}>
{def.label}
</span>
<span className="tabular-nums text-xs text-slate-400 dark:text-slate-500">
{fmtMoney(view.seriesTotals[def.key])}
</span>
</button>
);
})}
{highlighted && (
<button
type="button"
onClick={() => setHighlighted(null)}
className="ml-auto rounded-full px-3 py-1.5 text-xs font-medium text-indigo-600 underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
>
Clear emphasis
</button>
)}
</div>
{/* Screen-reader data table */}
<table className="sr-only">
<caption>
Monthly recurring revenue by stream, in thousands of dollars, H1
2026.
</caption>
<thead>
<tr>
<th scope="col">Month</th>
{SERIES.map((def) => (
<th key={def.key} scope="col">
{def.label}
</th>
))}
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
{DATA.map((row) => {
const total = SERIES.reduce((a, d) => a + row[d.key], 0);
return (
<tr key={row.month}>
<th scope="row">{row.month}</th>
{SERIES.map((def) => (
<td key={def.key}>{fmtMoney(row[def.key])}</td>
))}
<td>{fmtMoney(total)}</td>
</tr>
);
})}
</tbody>
</table>
</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 Progress
Originalhorizontal progress/metric chart

Chart Heatmap
Originalcontribution-style heatmap grid

Chart Bubble
OriginalSVG bubble/scatter chart

