UI Components Medium
Stacked Bar Chart
A stacked bar chart comparing year-over-year data by month. Features animated bars, legend, responsive layout, and configurable datasets with a clean corporate dashboard aesthetic.
Open in Lab
MCP
vanilla-js svg
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #f0f0f0;
--surface: #ffffff;
--border: #e5e7eb;
--text: #1a1a2e;
--text-muted: #6b7280;
--series-1: #1e3a5f;
--series-2: #60a5fa;
--grid: #e5e7eb;
--tooltip-bg: #ffffff;
--tooltip-shadow: rgba(0, 0, 0, 0.12);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 24px;
}
.chart-page {
max-width: 900px;
margin: 0 auto;
}
.chart-card {
background: var(--surface);
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 28px 24px 20px;
}
.chart-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.chart-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
}
.chart-sub {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 4px;
}
.chart-legend {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--text-muted);
font-weight: 500;
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.chart-wrap {
position: relative;
}
.chart-svg {
width: 100%;
display: block;
}
.bar-rect {
transition: opacity .15s;
cursor: pointer;
}
.bar-rect:hover {
opacity: 0.8;
}
.grid-line {
stroke: var(--grid);
stroke-width: 1;
}
.grid-label {
fill: var(--text-muted);
font-size: 10px;
font-family: inherit;
}
.x-label {
fill: var(--text-muted);
font-size: 10px;
font-family: inherit;
text-anchor: middle;
}
.chart-tooltip {
position: absolute;
background: var(--tooltip-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
font-size: 0.78rem;
pointer-events: none;
z-index: 10;
box-shadow: 0 4px 16px var(--tooltip-shadow);
color: var(--text);
}
.tooltip-label {
font-weight: 700;
margin-bottom: 4px;
}
.tooltip-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}const SERIES = [
{ key: "2024", label: "Year 2024", color: "#1e3a5f" },
{ key: "2025", label: "Year 2025", color: "#60a5fa" },
];
const DATA = [
{ month: "Jan", values: [125000, 82000] },
{ month: "Feb", values: [108000, 91000] },
{ month: "Mar", values: [142000, 76000] },
{ month: "Apr", values: [110000, 105000] },
{ month: "May", values: [97000, 118000] },
{ month: "Jun", values: [135000, 99000] },
{ month: "Jul", values: [151000, 88000] },
{ month: "Aug", values: [120000, 102000] },
{ month: "Sep", values: [113000, 124000] },
{ month: "Oct", values: [148000, 111000] },
{ month: "Nov", values: [162000, 106000] },
{ month: "Dec", values: [180000, 128000] },
];
const PAD = { top: 20, right: 20, bottom: 36, left: 80 };
const svg = document.getElementById("chartSvg");
const tooltip = document.getElementById("chartTooltip");
const wrap = document.getElementById("chartWrap");
const legend = document.getElementById("chartLegend");
// Build legend
SERIES.forEach((s) => {
const item = document.createElement("div");
item.className = "legend-item";
item.innerHTML =
'<span class="legend-swatch" style="background:' + s.color + '"></span>' + s.label;
legend.appendChild(item);
});
function formatMoney(v) {
return "$" + v.toLocaleString("en-US");
}
function el(tag, attrs) {
var e = document.createElementNS("http://www.w3.org/2000/svg", tag);
if (attrs)
Object.keys(attrs).forEach(function (k) {
e.setAttribute(k, attrs[k]);
});
return e;
}
function niceMax(val) {
var mag = Math.pow(10, Math.floor(Math.log10(val)));
return Math.ceil(val / mag) * mag;
}
function draw() {
var W = wrap.clientWidth;
var H = Math.max(320, Math.round(W * 0.48));
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.innerHTML = "";
var maxTotal = 0;
DATA.forEach(function (d) {
var sum = d.values.reduce(function (a, b) {
return a + b;
}, 0);
if (sum > maxTotal) maxTotal = sum;
});
maxTotal = niceMax(maxTotal);
var cW = W - PAD.left - PAD.right;
var cH = H - PAD.top - PAD.bottom;
var n = DATA.length;
var gap = Math.max(4, Math.round(cW * 0.02));
var barW = (cW - gap * (n - 1)) / n;
// Grid lines and Y labels
var ticks = 5;
for (var t = 0; t <= ticks; t++) {
var v = Math.round((maxTotal / ticks) * t);
var y = PAD.top + cH - (v / maxTotal) * cH;
svg.appendChild(
el("line", {
class: "grid-line",
x1: PAD.left,
x2: PAD.left + cW,
y1: y,
y2: y,
})
);
var lbl = el("text", {
class: "grid-label",
x: PAD.left - 8,
y: y + 3.5,
"text-anchor": "end",
});
lbl.textContent = formatMoney(v);
svg.appendChild(lbl);
}
// Bars
DATA.forEach(function (d, i) {
var x = PAD.left + i * (barW + gap);
var cumY = 0;
// Draw series bottom-to-top (series 0 at bottom, series 1 on top)
d.values.forEach(function (val, si) {
var barH = (val / maxTotal) * cH;
var yPos = PAD.top + cH - cumY - barH;
var rx = 0;
// Round top corners only for the topmost segment
if (si === d.values.length - 1) rx = 3;
var rect = el("rect", {
class: "bar-rect",
x: x,
y: yPos,
width: barW,
height: barH,
fill: SERIES[si].color,
rx: rx,
});
// Animation
rect.style.transformOrigin = "0 " + (PAD.top + cH) + "px";
rect.style.transform = "scaleY(0)";
rect.style.transition =
"transform .5s cubic-bezier(.4,0,.2,1) " + (i * 0.04 + si * 0.02) + "s";
(function (r) {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
r.style.transform = "scaleY(1)";
});
});
})(rect);
// Tooltip events
(function (month, series, value) {
rect.addEventListener("mouseenter", function (e) {
tooltip.innerHTML =
'<div class="tooltip-label">' +
month +
"</div>" +
'<div class="tooltip-row">' +
'<span class="tooltip-dot" style="background:' +
series.color +
'"></span>' +
"<span>" +
series.label +
": <strong>" +
formatMoney(value) +
"</strong></span>" +
"</div>";
tooltip.hidden = false;
posTooltip(e);
});
rect.addEventListener("mousemove", posTooltip);
rect.addEventListener("mouseleave", function () {
tooltip.hidden = true;
});
})(d.month, SERIES[si], val);
svg.appendChild(rect);
cumY += barH;
});
// X label
var xlbl = el("text", {
class: "x-label",
x: x + barW / 2,
y: H - 8,
});
xlbl.textContent = d.month;
svg.appendChild(xlbl);
});
}
function posTooltip(e) {
var r = wrap.getBoundingClientRect();
var x = e.clientX - r.left + 12;
var y = e.clientY - r.top - 44;
// Keep tooltip within bounds
if (x + 160 > r.width) x = e.clientX - r.left - 170;
if (y < 0) y = 4;
tooltip.style.left = x + "px";
tooltip.style.top = y + "px";
}
var ro = new ResizeObserver(draw);
ro.observe(wrap);
draw();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stacked Bar Chart</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-page">
<div class="chart-card">
<div class="chart-header">
<div>
<h1 class="chart-title">Revenue Overview</h1>
<p class="chart-sub">Monthly billing comparison (USD)</p>
</div>
<div class="chart-legend" id="chartLegend"></div>
</div>
<div class="chart-wrap" id="chartWrap">
<svg id="chartSvg" class="chart-svg" aria-label="Stacked bar chart: Billing behavior"></svg>
<div class="chart-tooltip" id="chartTooltip" hidden></div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Features
- Stacked bars — two series per month rendered as vertically stacked SVG rects
- Animated bars — scale-in animation from bottom on mount
- Legend — top-right legend showing both year series
- Y-axis formatting — monetary values with
$prefix and dot separators - Responsive — ResizeObserver redraws on container resize
- Hover tooltip — shows month, year, and formatted value on hover
- Light theme — clean corporate dashboard style with green palette
How it works
- Monthly data is defined as an array with two values (2024 and 2025) per month
- For each month two SVG
<rect>elements are stacked — the bottom series renders first, and the top series sits on top - Y-axis ticks are computed from the max stacked total and formatted as currency
transform: scaleY()animates each bar from 0 to 1 with staggered delays- A
ResizeObservertriggers a full redraw when the container changes size