SaaS — SaaS Metrics (MRR · Churn · LTV)
An investor-grade SaaS metrics dashboard built with vanilla HTML, CSS, and JavaScript. Headline cards surface MRR, ARR, churn, LTV, and net revenue retention with colored delta pills, beside an MRR-movement bar chart that stacks new, expansion, and churned revenue. A growth area chart, a cohort-retention heatmap, and a plan-mix donut complete the view. A 30d, 90d, and 12m period switch recomputes every figure and redraws all charts live, with hover tooltips, theme toggle, and keyboard support.
MCP
Code
:root {
--bg: #f7f8fb;
--surface: #ffffff;
--surface-2: #fbfcfe;
--ink: #0f1222;
--muted: #646b85;
--brand: #6366f1;
--brand-d: #4f46e5;
--ok: #16a34a;
--warn: #d97706;
--danger: #dc2626;
--exp: #0ea5e9;
--line: rgba(15, 18, 34, .1);
--shadow: 0 1px 2px rgba(15, 18, 34, .05), 0 8px 24px rgba(15, 18, 34, .06);
--radius: 14px;
}
[data-theme="dark"] {
--bg: #0b0e1a;
--surface: #141826;
--surface-2: #181d2e;
--ink: #eef1fb;
--muted: #9aa3c0;
--brand: #818cf8;
--brand-d: #6366f1;
--line: rgba(255, 255, 255, .1);
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 10px 30px rgba(0, 0, 0, .35);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background .25s ease, color .25s ease;
}
h1, h2 { margin: 0; letter-spacing: -.01em; }
h1 { font-size: 1.45rem; font-weight: 800; }
h2 { font-size: 1.02rem; font-weight: 700; }
p { margin: 0; }
.muted { color: var(--muted); font-size: .82rem; }
.app { max-width: 1180px; margin: 0 auto; padding: 0 20px 56px; }
/* Topbar */
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
background: color-mix(in srgb, var(--bg) 86%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
margin-bottom: 22px;
}
.brand { display: flex; align-items: center; gap: 11px; }
.brand-mark {
display: grid; place-items: center;
width: 38px; height: 38px;
border-radius: 11px;
color: #fff;
background: linear-gradient(135deg, var(--brand), var(--brand-d));
box-shadow: 0 6px 16px rgba(99, 102, 241, .35);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.15; }
.brand-text strong { font-size: 1rem; font-weight: 700; }
.brand-text span { font-size: .74rem; color: var(--muted); }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.seg {
display: inline-flex;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
padding: 3px;
box-shadow: var(--shadow);
}
.seg-btn {
border: 0; background: transparent;
font: inherit; font-size: .82rem; font-weight: 600;
color: var(--muted);
padding: 6px 13px; border-radius: 7px;
cursor: pointer; transition: all .18s ease;
}
.seg-btn:hover { color: var(--ink); }
.seg-btn.is-active {
background: linear-gradient(135deg, var(--brand), var(--brand-d));
color: #fff;
box-shadow: 0 3px 10px rgba(99, 102, 241, .4);
}
.icon-btn {
display: grid; place-items: center;
width: 38px; height: 38px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
border-radius: 10px;
cursor: pointer;
box-shadow: var(--shadow);
transition: transform .15s ease, border-color .15s ease;
}
.icon-btn:hover { transform: translateY(-1px); border-color: var(--brand); }
.i-moon { display: none; }
[data-theme="dark"] .i-sun { display: none; }
[data-theme="dark"] .i-moon { display: block; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
/* Page head */
.page-head {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 14px; flex-wrap: wrap; margin-bottom: 18px;
}
.live-pill {
display: inline-flex; align-items: center; gap: 7px;
font-size: .76rem; font-weight: 600; color: var(--muted);
background: var(--surface); border: 1px solid var(--line);
padding: 5px 11px; border-radius: 999px;
}
.live-pill .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent);
animation: pulse 2.2s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
/* KPI cards */
.cards {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
margin-bottom: 18px;
}
.kpi {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 15px 16px;
box-shadow: var(--shadow);
display: flex; flex-direction: column; gap: 6px;
min-width: 0;
}
.kpi header { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.kpi-name { font-size: .78rem; font-weight: 600; color: var(--muted); }
.kpi-value { font-size: 1.5rem; font-weight: 800; letter-spacing: -.02em; }
.kpi-sub { font-size: .73rem; color: var(--muted); }
.delta {
font-size: .72rem; font-weight: 700;
padding: 2px 7px; border-radius: 999px;
display: inline-flex; align-items: center; gap: 3px;
white-space: nowrap;
}
.delta.up { color: var(--ok); background: color-mix(in srgb, var(--ok) 14%, transparent); }
.delta.down { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); }
/* Grid */
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
min-width: 0;
}
.span-2 { grid-column: 1; }
.grid > .panel:nth-child(2) { grid-column: 2; grid-row: 1 / span 1; }
.panel-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px; margin-bottom: 14px;
}
.legend { list-style: none; margin: 0; padding: 0; display: flex; gap: 14px; flex-wrap: wrap; }
.legend li { display: inline-flex; align-items: center; gap: 6px; font-size: .76rem; color: var(--muted); }
.sw { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }
.sw-new { background: var(--brand); }
.sw-exp { background: var(--exp); }
.sw-churn { background: var(--danger); }
.growth-badge {
font-size: .76rem; font-weight: 700; color: var(--ok);
background: color-mix(in srgb, var(--ok) 13%, transparent);
padding: 4px 10px; border-radius: 999px;
}
/* Charts */
.chart-wrap { position: relative; }
#movementChart { width: 100%; height: 280px; display: block; overflow: visible; }
#growthChart { width: 100%; height: 240px; display: block; overflow: visible; }
.bar-new, .bar-exp, .bar-churn { cursor: pointer; transition: opacity .15s ease; }
.bar-new { fill: var(--brand); }
.bar-exp { fill: var(--exp); }
.bar-churn { fill: var(--danger); }
.bar-grp.dim .seg { opacity: .35; }
.grid-line { stroke: var(--line); stroke-width: 1; }
.axis-label { fill: var(--muted); font-size: 11px; font-family: inherit; }
.area-fill { fill: url(#areaGrad); }
.area-line { fill: none; stroke: var(--brand); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.area-dot { fill: var(--surface); stroke: var(--brand); stroke-width: 2.5; opacity: 0; transition: opacity .12s ease; }
.area-hit { fill: transparent; cursor: pointer; }
.area-cursor { stroke: var(--brand); stroke-width: 1; stroke-dasharray: 3 3; opacity: 0; }
.tooltip {
position: absolute;
pointer-events: none;
background: var(--ink);
color: var(--bg);
font-size: .74rem;
font-weight: 600;
padding: 7px 10px;
border-radius: 9px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .3);
transform: translate(-50%, -118%);
white-space: nowrap;
z-index: 5;
line-height: 1.45;
}
.tooltip b { color: #fff; }
[data-theme="dark"] .tooltip { background: #f1f3fb; color: #0b0e1a; }
[data-theme="dark"] .tooltip b { color: #0b0e1a; }
.tooltip .tt-row { display: flex; align-items: center; gap: 6px; }
.tooltip .tt-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
/* Donut */
.donut-wrap { position: relative; display: grid; place-items: center; margin: 4px 0 14px; }
#donutChart { width: 190px; height: 190px; }
.donut-seg { cursor: pointer; transition: opacity .15s ease; }
.donut-seg:hover { opacity: .82; }
.donut-center {
position: absolute; inset: 0;
display: grid; place-items: center; align-content: center;
text-align: center; pointer-events: none;
}
.donut-center strong { font-size: 1.3rem; font-weight: 800; }
.donut-center span { font-size: .72rem; color: var(--muted); }
.donut-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.donut-legend li { display: flex; align-items: center; gap: 9px; font-size: .82rem; }
.donut-legend .dl-sw { width: 11px; height: 11px; border-radius: 3px; }
.donut-legend .dl-name { flex: 1; min-width: 0; }
.donut-legend .dl-val { font-weight: 700; }
.donut-legend .dl-pct { color: var(--muted); font-size: .76rem; width: 42px; text-align: right; }
/* Heatmap */
.heatmap {
display: grid;
grid-template-columns: 64px repeat(6, 1fr);
gap: 4px;
font-size: .72rem;
}
.heat-h, .heat-y { color: var(--muted); display: flex; align-items: center; font-weight: 600; }
.heat-h { justify-content: center; }
.heat-cell {
aspect-ratio: 1 / .62;
border-radius: 6px;
display: grid; place-items: center;
font-weight: 700;
color: #fff;
font-size: .7rem;
cursor: default;
transition: transform .12s ease;
}
.heat-cell:hover { transform: scale(1.06); }
.heat-cell.empty { background: transparent; color: var(--muted); cursor: default; }
.heat-scale {
display: flex; align-items: center; gap: 8px;
margin-top: 12px; font-size: .72rem; color: var(--muted);
}
.heat-bar {
flex: 1; height: 7px; border-radius: 999px;
background: linear-gradient(90deg, #fde68a, #34d399, #059669);
}
/* Toast */
.toast {
position: fixed; left: 50%; bottom: 26px;
transform: translate(-50%, 18px);
background: var(--ink); color: var(--bg);
font-size: .84rem; font-weight: 600;
padding: 11px 18px; border-radius: 11px;
box-shadow: 0 12px 32px rgba(0, 0, 0, .3);
opacity: 0; pointer-events: none;
transition: opacity .25s ease, transform .25s ease;
z-index: 50;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
[data-theme="dark"] .toast { background: #f1f3fb; color: #0b0e1a; }
/* Responsive */
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
.span-2, .grid > .panel:nth-child(2) { grid-column: 1; }
.grid > .panel:nth-child(2) { grid-row: auto; }
.cards { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
.app { padding: 0 14px 44px; }
.cards { grid-template-columns: repeat(2, 1fr); }
.topbar { flex-wrap: wrap; }
h1 { font-size: 1.25rem; }
.brand-text span { display: none; }
}
@media (max-width: 400px) {
.cards { grid-template-columns: 1fr; }
.heatmap { grid-template-columns: 52px repeat(6, 1fr); font-size: .64rem; }
}(function () {
"use strict";
var SVGNS = "http://www.w3.org/2000/svg";
/* ---------- helpers ---------- */
function el(tag, attrs, ns) {
var node = ns ? document.createElementNS(ns, tag) : document.createElement(tag);
if (attrs) for (var k in attrs) {
if (k === "text") node.textContent = attrs[k];
else if (k === "class") node.setAttribute("class", attrs[k]);
else node.setAttribute(k, attrs[k]);
}
return node;
}
function fmtMoney(n) {
if (n >= 1000000) return "$" + (n / 1000000).toFixed(2).replace(/\.00$/, "") + "M";
if (n >= 1000) return "$" + (n / 1000).toFixed(1).replace(/\.0$/, "") + "K";
return "$" + Math.round(n).toLocaleString();
}
function fmtMoneyFull(n) { return "$" + Math.round(n).toLocaleString(); }
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
var toastEl = document.getElementById("toast");
var toastT;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastT);
toastT = setTimeout(function () { toastEl.classList.remove("show"); }, 2200);
}
/* ---------- data model (deterministic per period) ---------- */
var PERIODS = {
"30d": {
label: "Trailing 30 days · vs previous 30 days",
months: ["W1", "W2", "W3", "W4"],
growthLabels: ["W1", "W2", "W3", "W4"],
// monthly movement: [new, expansion, churned]
movement: [[58, 22, -19], [64, 27, -16], [61, 31, -23], [72, 29, -18]],
baseMrr: 1980000,
churnPct: 2.4, churnPrev: 2.7,
nrr: 109, nrrPrev: 107,
ltv: 4120, ltvPrev: 3980,
mrrDelta: 4.1, arrDelta: 4.1,
plans: [["Starter", 0.16, "#a5b4fc"], ["Growth", 0.41, "#6366f1"], ["Scale", 0.31, "#4338ca"], ["Enterprise", 0.12, "#312e81"]],
cohorts: [
["Mar", [100, 91, 84, 80, 77, 75]],
["Apr", [100, 89, 83, 79, 76, null]],
["May", [100, 92, 86, 82, null, null]],
["Jun", [100, 90, 85, null, null, null]]
]
},
"90d": {
label: "Trailing 90 days · vs previous 90 days",
months: ["Apr", "May", "Jun"],
movement: [[176, 78, -58], [188, 91, -52], [203, 84, -61]],
baseMrr: 1820000,
churnPct: 2.7, churnPrev: 3.1,
nrr: 107, nrrPrev: 104,
ltv: 3980, ltvPrev: 3760,
mrrDelta: 8.7, arrDelta: 8.7,
plans: [["Starter", 0.18, "#a5b4fc"], ["Growth", 0.40, "#6366f1"], ["Scale", 0.30, "#4338ca"], ["Enterprise", 0.12, "#312e81"]],
cohorts: [
["Jan", [100, 88, 80, 74, 70, 67]],
["Feb", [100, 90, 82, 76, 72, null]],
["Mar", [100, 91, 84, 80, null, null]],
["Apr", [100, 89, 83, null, null, null]]
]
},
"12m": {
label: "Trailing 12 months · vs previous 12 months",
months: ["Q1", "Q2", "Q3", "Q4"],
movement: [[520, 210, -180], [580, 248, -172], [640, 271, -201], [712, 305, -188]],
baseMrr: 1240000,
churnPct: 3.2, churnPrev: 4.0,
nrr: 104, nrrPrev: 99,
ltv: 3760, ltvPrev: 3210,
mrrDelta: 38.4, arrDelta: 38.4,
plans: [["Starter", 0.21, "#a5b4fc"], ["Growth", 0.39, "#6366f1"], ["Scale", 0.28, "#4338ca"], ["Enterprise", 0.12, "#312e81"]],
cohorts: [
["Q3'24", [100, 85, 76, 70, 66, 63]],
["Q4'24", [100, 87, 79, 73, 69, null]],
["Q1'25", [100, 89, 82, 77, null, null]],
["Q2'25", [100, 90, 84, null, null, null]]
]
}
};
var current = "30d";
/* ---------- KPI cards ---------- */
function setDelta(card, val, goodWhenUp) {
var d = card.querySelector("[data-delta]");
var up = val >= 0;
var positive = goodWhenUp ? up : !up;
d.className = "delta " + (positive ? "up" : "down");
d.textContent = (up ? "▲ " : "▼ ") + Math.abs(val).toFixed(1) + "%";
}
function renderKpis(d) {
var totalMrr = d.baseMrr;
var arr = totalMrr * 12;
var mrr = document.getElementById("kpi-mrr");
mrr.querySelector("[data-value]").textContent = fmtMoney(totalMrr);
mrr.querySelector("[data-sub]").textContent = "Monthly recurring revenue";
setDelta(mrr, d.mrrDelta, true);
var ar = document.getElementById("kpi-arr");
ar.querySelector("[data-value]").textContent = fmtMoney(arr);
ar.querySelector("[data-sub]").textContent = "Annual run rate";
setDelta(ar, d.arrDelta, true);
var ch = document.getElementById("kpi-churn");
ch.querySelector("[data-value]").textContent = d.churnPct.toFixed(1) + "%";
ch.querySelector("[data-sub]").textContent = "Gross revenue churn";
setDelta(ch, ((d.churnPct - d.churnPrev) / d.churnPrev) * 100, false);
var lt = document.getElementById("kpi-ltv");
lt.querySelector("[data-value]").textContent = fmtMoneyFull(d.ltv);
lt.querySelector("[data-sub]").textContent = "Avg. customer lifetime value";
setDelta(lt, ((d.ltv - d.ltvPrev) / d.ltvPrev) * 100, true);
var nr = document.getElementById("kpi-nrr");
nr.querySelector("[data-value]").textContent = d.nrr + "%";
nr.querySelector("[data-sub]").textContent = "Net revenue retention";
setDelta(nr, ((d.nrr - d.nrrPrev) / d.nrrPrev) * 100, true);
}
/* ---------- MRR movement stacked bars ---------- */
var movTip = document.getElementById("movementTip");
function renderMovement(d) {
var svg = document.getElementById("movementChart");
svg.innerHTML = "";
var W = 720, H = 280, padL = 46, padB = 30, padT = 14, padR = 8;
var plotW = W - padL - padR, plotH = H - padB - padT;
var maxUp = 0, maxDn = 0;
d.movement.forEach(function (m) {
maxUp = Math.max(maxUp, m[0] + m[1]);
maxDn = Math.max(maxDn, -m[2]);
});
var max = Math.ceil((maxUp + maxDn * 0) * 1.12);
var scaleMax = Math.ceil(Math.max(maxUp, maxDn) * 1.15 / 10) * 10;
var zeroY = padT + plotH * (scaleMax / (scaleMax * 2));
function yUp(v) { return zeroY - (v / scaleMax) * (plotH / 2); }
function yDn(v) { return zeroY + (v / scaleMax) * (plotH / 2); }
// gridlines
[scaleMax, scaleMax / 2, 0, -scaleMax / 2, -scaleMax].forEach(function (gv) {
var y = gv >= 0 ? yUp(gv) : yDn(-gv);
svg.appendChild(el("line", { x1: padL, x2: W - padR, y1: y, y2: y, class: "grid-line" }, SVGNS));
var lbl = el("text", { x: padL - 8, y: y + 3, class: "axis-label", "text-anchor": "end" }, SVGNS);
lbl.textContent = "$" + Math.round(gv) + "K";
svg.appendChild(lbl);
});
var n = d.movement.length;
var slot = plotW / n;
var bw = Math.min(46, slot * 0.5);
d.movement.forEach(function (m, i) {
var cx = padL + slot * i + slot / 2;
var x = cx - bw / 2;
var g = el("g", { class: "bar-grp" }, SVGNS);
var newH = (m[0] / scaleMax) * (plotH / 2);
var expH = (m[1] / scaleMax) * (plotH / 2);
var churnH = (-m[2] / scaleMax) * (plotH / 2);
var rNew = el("rect", { class: "seg bar-new", x: x, y: zeroY - newH, width: bw, height: newH, rx: 3 }, SVGNS);
var rExp = el("rect", { class: "seg bar-exp", x: x, y: zeroY - newH - expH, width: bw, height: expH, rx: 3 }, SVGNS);
var rCh = el("rect", { class: "seg bar-churn", x: x, y: zeroY, width: bw, height: churnH, rx: 3 }, SVGNS);
g.appendChild(rNew); g.appendChild(rExp); g.appendChild(rCh);
var lbl = el("text", { x: cx, y: H - 10, class: "axis-label", "text-anchor": "middle" }, SVGNS);
lbl.textContent = d.months[i];
svg.appendChild(lbl);
// hover hit-area
var hit = el("rect", { x: x - 6, y: padT, width: bw + 12, height: plotH, fill: "transparent", style: "cursor:pointer" }, SVGNS);
hit.addEventListener("mousemove", function (ev) {
showMovTip(ev, svg, d.months[i], m);
Array.prototype.forEach.call(svg.querySelectorAll(".bar-grp"), function (bg) {
bg.classList.toggle("dim", bg !== g);
});
});
hit.addEventListener("mouseleave", function () {
movTip.hidden = true;
Array.prototype.forEach.call(svg.querySelectorAll(".bar-grp"), function (bg) { bg.classList.remove("dim"); });
});
g.appendChild(hit);
svg.appendChild(g);
});
// zero baseline
svg.appendChild(el("line", { x1: padL, x2: W - padR, y1: zeroY, y2: zeroY, stroke: "var(--muted)", "stroke-width": 1.4 }, SVGNS));
}
function showMovTip(ev, svg, label, m) {
var net = m[0] + m[1] + m[2];
movTip.innerHTML =
'<div style="margin-bottom:4px"><b>' + label + '</b></div>' +
'<div class="tt-row"><span class="tt-dot" style="background:var(--brand)"></span>New $' + m[0] + 'K</div>' +
'<div class="tt-row"><span class="tt-dot" style="background:var(--exp)"></span>Expansion $' + m[1] + 'K</div>' +
'<div class="tt-row"><span class="tt-dot" style="background:var(--danger)"></span>Churned $' + m[2] + 'K</div>' +
'<div style="margin-top:4px;border-top:1px solid rgba(255,255,255,.2);padding-top:3px"><b>Net +$' + net + 'K</b></div>';
positionTip(movTip, ev, svg);
}
function positionTip(tip, ev, svg) {
var rect = svg.getBoundingClientRect();
var wrap = svg.parentElement;
var wrapRect = wrap.getBoundingClientRect();
tip.hidden = false;
var x = ev.clientX - wrapRect.left;
var y = ev.clientY - wrapRect.top;
tip.style.left = clamp(x, 60, wrapRect.width - 60) + "px";
tip.style.top = y + "px";
}
/* ---------- Growth area chart ---------- */
var grTip = document.getElementById("growthTip");
var grPts = [];
function renderGrowth(d) {
var svg = document.getElementById("growthChart");
svg.innerHTML = "";
var W = 720, H = 240, padL = 46, padB = 26, padT = 14, padR = 8;
var plotW = W - padL - padR, plotH = H - padB - padT;
// build cumulative recurring revenue series from movement nets
var start = d.baseMrr * 0.84;
var series = [];
var running = start;
series.push(start);
d.movement.forEach(function (m) {
running += (m[0] + m[1] + m[2]) * 1000 * (d.baseMrr / 1980000);
series.push(running);
});
// normalize last to baseMrr
var scale = d.baseMrr / series[series.length - 1];
series = series.map(function (v) { return v * scale; });
var labels = ["start"].concat(d.months);
var min = Math.min.apply(null, series) * 0.96;
var max = Math.max.apply(null, series) * 1.04;
function x(i) { return padL + (plotW * i) / (series.length - 1); }
function y(v) { return padT + plotH - ((v - min) / (max - min)) * plotH; }
// gridlines
for (var gi = 0; gi <= 3; gi++) {
var gv = min + ((max - min) * gi) / 3;
var gy = y(gv);
svg.appendChild(el("line", { x1: padL, x2: W - padR, y1: gy, y2: gy, class: "grid-line" }, SVGNS));
var t = el("text", { x: padL - 8, y: gy + 3, class: "axis-label", "text-anchor": "end" }, SVGNS);
t.textContent = fmtMoney(gv);
svg.appendChild(t);
}
// gradient def
var defs = el("defs", null, SVGNS);
var grad = el("linearGradient", { id: "areaGrad", x1: 0, y1: 0, x2: 0, y2: 1 }, SVGNS);
grad.appendChild(el("stop", { offset: "0%", "stop-color": "var(--brand)", "stop-opacity": ".34" }, SVGNS));
grad.appendChild(el("stop", { offset: "100%", "stop-color": "var(--brand)", "stop-opacity": "0" }, SVGNS));
defs.appendChild(grad);
svg.appendChild(defs);
var linePath = "", areaPath = "";
grPts = [];
series.forEach(function (v, i) {
var px = x(i), py = y(v);
grPts.push({ x: px, y: py, v: v, label: labels[i] });
linePath += (i === 0 ? "M" : "L") + px.toFixed(1) + " " + py.toFixed(1) + " ";
});
areaPath = linePath + "L" + x(series.length - 1) + " " + (padT + plotH) + " L" + padL + " " + (padT + plotH) + " Z";
svg.appendChild(el("path", { d: areaPath, class: "area-fill" }, SVGNS));
svg.appendChild(el("path", { d: linePath.trim(), class: "area-line" }, SVGNS));
var cursor = el("line", { class: "area-cursor", y1: padT, y2: padT + plotH, x1: 0, x2: 0 }, SVGNS);
svg.appendChild(cursor);
var dot = el("circle", { class: "area-dot", r: 5, cx: 0, cy: 0 }, SVGNS);
svg.appendChild(dot);
// labels
labels.forEach(function (lb, i) {
if (i === 0) return;
var t = el("text", { x: x(i), y: H - 8, class: "axis-label", "text-anchor": "middle" }, SVGNS);
t.textContent = lb;
svg.appendChild(t);
});
var hit = el("rect", { class: "area-hit", x: padL, y: padT, width: plotW, height: plotH }, SVGNS);
hit.addEventListener("mousemove", function (ev) {
var pt = svgPointX(svg, ev);
var nearest = grPts[0], nd = Infinity;
grPts.forEach(function (p) { var dd = Math.abs(p.x - pt); if (dd < nd) { nd = dd; nearest = p; } });
dot.setAttribute("cx", nearest.x); dot.setAttribute("cy", nearest.y); dot.style.opacity = 1;
cursor.setAttribute("x1", nearest.x); cursor.setAttribute("x2", nearest.x); cursor.style.opacity = 1;
grTip.innerHTML = '<b>' + fmtMoneyFull(nearest.v) + '</b><br>' + (nearest.label === "start" ? "period start" : nearest.label + " · MRR");
grTip.hidden = false;
var wrapRect = svg.parentElement.getBoundingClientRect();
var svgRect = svg.getBoundingClientRect();
var ratio = svgRect.width / W;
grTip.style.left = clamp(nearest.x * ratio, 60, wrapRect.width - 60) + "px";
grTip.style.top = (nearest.y * (svgRect.height / H)) + "px";
});
hit.addEventListener("mouseleave", function () {
grTip.hidden = true; dot.style.opacity = 0; cursor.style.opacity = 0;
});
svg.appendChild(hit);
// growth badge
var pct = ((series[series.length - 1] - series[0]) / series[0]) * 100;
var badge = document.getElementById("growthBadge");
badge.textContent = (pct >= 0 ? "+" : "") + pct.toFixed(1) + "% growth";
badge.style.color = pct >= 0 ? "var(--ok)" : "var(--danger)";
}
function svgPointX(svg, ev) {
var r = svg.getBoundingClientRect();
return ((ev.clientX - r.left) / r.width) * 720;
}
/* ---------- Donut ---------- */
function renderDonut(d) {
var svg = document.getElementById("donutChart");
svg.innerHTML = "";
var cx = 100, cy = 100, r = 74, rin = 50;
var total = d.baseMrr;
var legend = document.getElementById("donutLegend");
legend.innerHTML = "";
document.getElementById("donutTotal").textContent = fmtMoney(total);
var angle = -90;
d.plans.forEach(function (p) {
var name = p[0], frac = p[1], color = p[2];
var sweep = frac * 360;
var a0 = (angle * Math.PI) / 180;
var a1 = ((angle + sweep) * Math.PI) / 180;
var large = sweep > 180 ? 1 : 0;
var x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
var x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
var xi0 = cx + rin * Math.cos(a0), yi0 = cy + rin * Math.sin(a0);
var xi1 = cx + rin * Math.cos(a1), yi1 = cy + rin * Math.sin(a1);
var path = "M" + x0 + " " + y0 +
" A" + r + " " + r + " 0 " + large + " 1 " + x1 + " " + y1 +
" L" + xi1 + " " + yi1 +
" A" + rin + " " + rin + " 0 " + large + " 0 " + xi0 + " " + yi0 + " Z";
var seg = el("path", { d: path, fill: color, class: "donut-seg" }, SVGNS);
var val = total * frac;
seg.addEventListener("mouseenter", function () {
document.getElementById("donutTotal").textContent = fmtMoney(val);
});
seg.addEventListener("mouseleave", function () {
document.getElementById("donutTotal").textContent = fmtMoney(total);
});
seg.addEventListener("click", function () { toast(name + " · " + fmtMoneyFull(val) + " MRR (" + Math.round(frac * 100) + "%)"); });
svg.appendChild(seg);
angle += sweep;
var li = el("li");
li.appendChild(el("span", { class: "dl-sw", style: "background:" + color }));
li.appendChild(el("span", { class: "dl-name", text: name }));
li.appendChild(el("span", { class: "dl-val", text: fmtMoney(val) }));
li.appendChild(el("span", { class: "dl-pct", text: Math.round(frac * 100) + "%" }));
legend.appendChild(li);
});
}
/* ---------- Heatmap ---------- */
function heatColor(v) {
// 60..100 → amber → green
var t = clamp((v - 58) / (100 - 58), 0, 1);
// interpolate amber (253,230,138) -> teal (52,211,153) -> deep green (5,150,105)
var c1 = [253, 230, 138], c2 = [52, 211, 153], c3 = [5, 150, 105];
var rgb;
if (t < 0.5) { var k = t / 0.5; rgb = mix(c1, c2, k); }
else { var k2 = (t - 0.5) / 0.5; rgb = mix(c2, c3, k2); }
return "rgb(" + rgb.join(",") + ")";
}
function mix(a, b, t) { return a.map(function (v, i) { return Math.round(v + (b[i] - v) * t); }); }
function renderHeatmap(d) {
var hm = document.getElementById("heatmap");
hm.innerHTML = "";
// header row
hm.appendChild(el("div", { class: "heat-h", text: "Cohort" }));
["M0", "M1", "M2", "M3", "M4", "M5"].forEach(function (h) {
hm.appendChild(el("div", { class: "heat-h", text: h }));
});
d.cohorts.forEach(function (row) {
hm.appendChild(el("div", { class: "heat-y", text: row[0] }));
row[1].forEach(function (v, i) {
if (v === null) {
hm.appendChild(el("div", { class: "heat-cell empty", text: "·" }));
} else {
var cell = el("div", { class: "heat-cell", text: v + "%" });
cell.style.background = heatColor(v);
if (v > 88) cell.style.color = "#06281c";
cell.title = row[0] + " cohort · " + v + "% retained at month " + i;
hm.appendChild(cell);
}
});
});
}
/* ---------- render all ---------- */
function renderAll() {
var d = PERIODS[current];
document.getElementById("rangeLabel").textContent = d.label;
renderKpis(d);
renderMovement(d);
renderGrowth(d);
renderDonut(d);
renderHeatmap(d);
}
/* ---------- period switch ---------- */
var segBtns = document.querySelectorAll(".seg-btn");
Array.prototype.forEach.call(segBtns, function (btn) {
btn.addEventListener("click", function () {
if (btn.classList.contains("is-active")) return;
Array.prototype.forEach.call(segBtns, function (b) {
b.classList.remove("is-active");
b.setAttribute("aria-selected", "false");
});
btn.classList.add("is-active");
btn.setAttribute("aria-selected", "true");
current = btn.dataset.period;
renderAll();
toast("Period: " + btn.textContent.trim());
});
});
/* ---------- theme toggle ---------- */
var themeBtn = document.getElementById("themeToggle");
themeBtn.addEventListener("click", function () {
var html = document.documentElement;
var next = html.getAttribute("data-theme") === "dark" ? "light" : "dark";
html.setAttribute("data-theme", next);
// redraw charts that depend on CSS vars resolved at draw time is fine; re-render for gradients
renderAll();
toast(next === "dark" ? "Dark theme" : "Light theme");
});
// redraw on resize (tooltips/positions depend on px)
var rt;
window.addEventListener("resize", function () {
clearTimeout(rt);
rt = setTimeout(renderAll, 160);
});
renderAll();
})();<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meridian — SaaS Metrics</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="topbar" role="banner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M3 17l5-6 4 4 5-8 4 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<div class="brand-text">
<strong>Meridian</strong>
<span>Revenue analytics</span>
</div>
</div>
<div class="topbar-actions">
<div class="seg" role="tablist" aria-label="Reporting period">
<button class="seg-btn is-active" role="tab" aria-selected="true" data-period="30d">30d</button>
<button class="seg-btn" role="tab" aria-selected="false" data-period="90d">90d</button>
<button class="seg-btn" role="tab" aria-selected="false" data-period="12m">12m</button>
</div>
<button class="icon-btn" id="themeToggle" aria-label="Toggle dark mode" title="Toggle theme">
<svg class="i-sun" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<svg class="i-moon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.3 6.3 0 0 0 10.5 10.5Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
</div>
</header>
<main class="content" role="main">
<div class="page-head">
<div>
<h1>SaaS metrics</h1>
<p class="muted" id="rangeLabel">Trailing 30 days · vs previous 30 days</p>
</div>
<span class="live-pill"><span class="dot" aria-hidden="true"></span> Live · fictional data</span>
</div>
<!-- Headline cards -->
<section class="cards" aria-label="Headline metrics">
<article class="kpi" id="kpi-mrr">
<header><span class="kpi-name">MRR</span><span class="delta" data-delta></span></header>
<strong class="kpi-value" data-value></strong>
<span class="kpi-sub" data-sub></span>
</article>
<article class="kpi" id="kpi-arr">
<header><span class="kpi-name">ARR</span><span class="delta" data-delta></span></header>
<strong class="kpi-value" data-value></strong>
<span class="kpi-sub" data-sub></span>
</article>
<article class="kpi" id="kpi-churn">
<header><span class="kpi-name">Gross churn</span><span class="delta" data-delta></span></header>
<strong class="kpi-value" data-value></strong>
<span class="kpi-sub" data-sub></span>
</article>
<article class="kpi" id="kpi-ltv">
<header><span class="kpi-name">LTV</span><span class="delta" data-delta></span></header>
<strong class="kpi-value" data-value></strong>
<span class="kpi-sub" data-sub></span>
</article>
<article class="kpi" id="kpi-nrr">
<header><span class="kpi-name">Net revenue retention</span><span class="delta" data-delta></span></header>
<strong class="kpi-value" data-value></strong>
<span class="kpi-sub" data-sub></span>
</article>
</section>
<div class="grid">
<!-- MRR movement -->
<section class="panel span-2" aria-label="MRR movement">
<div class="panel-head">
<div>
<h2>MRR movement</h2>
<p class="muted">New & expansion against churned, by month</p>
</div>
<ul class="legend" aria-hidden="true">
<li><span class="sw sw-new"></span>New</li>
<li><span class="sw sw-exp"></span>Expansion</li>
<li><span class="sw sw-churn"></span>Churned</li>
</ul>
</div>
<div class="chart-wrap">
<svg id="movementChart" viewBox="0 0 720 280" preserveAspectRatio="none" role="img" aria-label="Stacked bar chart of MRR movement"></svg>
<div class="tooltip" id="movementTip" role="status" aria-live="polite" hidden></div>
</div>
</section>
<!-- Plan mix donut -->
<section class="panel" aria-label="Plan mix">
<div class="panel-head">
<div><h2>Plan mix</h2><p class="muted">Share of MRR by tier</p></div>
</div>
<div class="donut-wrap">
<svg id="donutChart" viewBox="0 0 200 200" role="img" aria-label="Donut chart of revenue by plan"></svg>
<div class="donut-center">
<strong id="donutTotal">—</strong>
<span>total MRR</span>
</div>
</div>
<ul class="donut-legend" id="donutLegend"></ul>
</section>
<!-- Growth area -->
<section class="panel span-2" aria-label="Recurring revenue growth">
<div class="panel-head">
<div><h2>Recurring revenue</h2><p class="muted">Cumulative MRR over the period</p></div>
<span class="growth-badge" id="growthBadge">—</span>
</div>
<div class="chart-wrap">
<svg id="growthChart" viewBox="0 0 720 240" preserveAspectRatio="none" role="img" aria-label="Area chart of recurring revenue"></svg>
<div class="tooltip" id="growthTip" role="status" aria-live="polite" hidden></div>
</div>
</section>
<!-- Cohort heatmap -->
<section class="panel" aria-label="Cohort retention">
<div class="panel-head">
<div><h2>Cohort retention</h2><p class="muted">% active by months since signup</p></div>
</div>
<div class="heatmap" id="heatmap" aria-hidden="false"></div>
<div class="heat-scale" aria-hidden="true">
<span>low</span>
<span class="heat-bar"></span>
<span>high</span>
</div>
</section>
</div>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>SaaS Metrics (MRR · Churn · LTV)
A board-ready metrics console for a fictional analytics product, Meridian. Five headline cards lead with MRR, ARR, gross churn, customer LTV, and net revenue retention — each carrying a colored delta pill against the prior period. Below them, an MRR-movement chart stacks new, expansion, and churned revenue as inline SVG bars, a growth area chart traces recurring revenue over time, a cohort-retention heatmap grades monthly survival, and a plan-mix donut breaks revenue down by tier.
The 30d / 90d / 12m period switch is the core interaction: choosing a window deterministically recomputes every headline figure and delta, then redraws the movement bars, growth area, heatmap, and donut in place. Hovering any chart snaps a tooltip to the nearest data point, and a working light/dark theme toggle repaints the whole shell and its charts.
Everything is self-contained vanilla JS — inline SVG charts only, no frameworks, no build, no external images — with landmark roles, aria states, focus-visible rings, and a layout that collapses to a single column on phones.
Illustrative SaaS UI only — fictional product, metrics, and billing. No real backend.