Widget — KPI / stat row (with trends)
A copy-paste KPI strip of five stat cards for a fictional SaaS — new sign-ups, revenue, active users, churn and NPS — each pairing a big tabular value with a coloured delta chip, an up or down arrow, a vs-last-period caption and a tiny inline-SVG sparkline that tracks the trend. A Today / Week / Month toggle swaps every value, delta and sparkline with an ease-out count-up, a live toggle nudges the figures every few seconds, and a channel-mix bar gives the row a data-dense companion. Pure HTML, CSS and vanilla JavaScript, no chart libraries.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
p {
margin: 0;
}
button {
font-family: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: var(--r-sm);
}
.page {
max-width: 1120px;
margin: 0 auto;
padding: clamp(20px, 4vw, 48px) clamp(16px, 4vw, 40px) 64px;
}
/* ---------- Header ---------- */
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.eyebrow {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--brand);
margin-bottom: 6px;
}
.head-text h1 {
font-size: clamp(24px, 4vw, 32px);
font-weight: 800;
letter-spacing: -0.02em;
}
.sub {
margin-top: 6px;
font-size: 14px;
color: var(--muted);
max-width: 46ch;
}
.head-controls {
display: flex;
align-items: center;
gap: 10px;
}
.period {
display: inline-flex;
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px;
box-shadow: var(--sh-1);
}
.period-btn {
border: 0;
background: transparent;
color: var(--ink-2);
font-size: 13px;
font-weight: 600;
padding: 7px 16px;
border-radius: 999px;
transition: color 0.15s, background 0.15s;
}
.period-btn:hover {
color: var(--ink);
}
.period-btn.is-active {
background: var(--brand);
color: #fff;
box-shadow: 0 2px 8px rgba(91, 91, 240, 0.35);
}
.live-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
background: var(--white);
color: var(--muted);
font-size: 13px;
font-weight: 600;
padding: 8px 14px;
border-radius: 999px;
box-shadow: var(--sh-1);
transition: color 0.15s, border-color 0.15s;
}
.live-toggle .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--line-2);
}
.live-toggle.is-on {
color: var(--ok);
border-color: rgba(47, 158, 111, 0.4);
}
.live-toggle.is-on .dot {
background: var(--ok);
box-shadow: 0 0 0 0 rgba(47, 158, 111, 0.5);
animation: pulse 1.8s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(47, 158, 111, 0.5);
}
70% {
box-shadow: 0 0 0 7px rgba(47, 158, 111, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(47, 158, 111, 0);
}
}
.period-caption {
margin-top: 18px;
font-size: 13px;
color: var(--muted);
}
/* ---------- Stat row ---------- */
.stat-row {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.stat-card {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 16px 12px;
box-shadow: var(--sh-1);
overflow: hidden;
transition: transform 0.18s, box-shadow 0.18s, border-color 0.18s;
}
.stat-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--brand);
opacity: 0.85;
}
.stat-card[data-key="churn"]::before {
background: var(--warn);
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: var(--sh-2);
border-color: var(--line-2);
}
.stat-card.is-flash {
animation: flash 0.6s ease;
}
@keyframes flash {
0% {
background: var(--brand-50);
}
100% {
background: var(--surface);
}
}
.stat-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.stat-card__label {
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.01em;
}
.stat-card__menu {
border: 0;
background: transparent;
color: var(--muted);
font-size: 16px;
line-height: 1;
padding: 2px 6px;
border-radius: var(--r-sm);
transition: background 0.15s, color 0.15s;
}
.stat-card__menu:hover {
background: var(--brand-50);
color: var(--brand);
}
.stat-card__value {
margin-top: 10px;
font-size: clamp(22px, 2.4vw, 28px);
font-weight: 800;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: var(--ink);
}
.stat-card__foot {
margin-top: 6px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.delta {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12.5px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.delta--up {
color: var(--ok);
background: rgba(47, 158, 111, 0.12);
}
.delta--down {
color: var(--danger);
background: rgba(212, 80, 62, 0.12);
}
.delta__arrow {
font-size: 9px;
}
.stat-card__caption {
font-size: 11.5px;
color: var(--muted);
}
.spark {
margin-top: 12px;
display: block;
width: 100%;
height: 36px;
}
.spark__line {
fill: none;
stroke: var(--brand);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.spark__fill {
fill: rgba(91, 91, 240, 0.12);
stroke: none;
}
.stat-card[data-key="churn"] .spark__line {
stroke: var(--warn);
}
.stat-card[data-key="churn"] .spark__fill {
fill: rgba(217, 138, 43, 0.13);
}
.stat-card.is-neg .spark__line {
stroke: var(--danger);
}
.stat-card.is-neg .spark__fill {
fill: rgba(212, 80, 62, 0.12);
}
/* ---------- Supporting panel ---------- */
.panel {
margin-top: 28px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 20px 22px;
box-shadow: var(--sh-1);
}
.panel__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.panel__head h2 {
font-size: 15px;
font-weight: 700;
}
.panel__hint {
font-size: 12px;
font-weight: 600;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--line);
padding: 3px 10px;
border-radius: 999px;
}
.mix {
display: flex;
height: 16px;
border-radius: 999px;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--line);
}
.mix__seg {
height: 100%;
transition: flex-basis 0.6s cubic-bezier(0.22, 1, 0.36, 1);
min-width: 2px;
}
.legend {
list-style: none;
margin: 14px 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 18px;
}
.legend li {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ink-2);
}
.legend .swatch {
width: 10px;
height: 10px;
border-radius: 3px;
}
.legend strong {
font-weight: 700;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.legend small {
color: var(--muted);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 16px);
background: var(--ink);
color: #fff;
font-size: 13px;
font-weight: 500;
padding: 10px 16px;
border-radius: 999px;
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 50;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 1040px) {
.stat-row {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 720px) {
.stat-row {
grid-template-columns: repeat(2, 1fr);
}
.page-head {
align-items: flex-start;
}
.head-controls {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 420px) {
.stat-row {
grid-template-columns: 1fr;
}
.period-btn {
padding: 7px 12px;
}
.legend {
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
// ---- Fictional dataset, one entry per period ----------------------------
// Each KPI carries: raw value, formatter, delta %, direction, comparison
// caption, and an 8-point trend series used to draw the sparkline.
var DATA = {
today: {
caption: "Comparing today against yesterday.",
compare: "vs yesterday",
hint: "Today",
stats: {
signups: { value: 1284, fmt: "int", delta: 8.4, dir: "up", spark: [9, 11, 10, 13, 12, 15, 14, 18] },
revenue: { value: 48200, fmt: "money", delta: 12.1, dir: "up", spark: [30, 34, 33, 38, 41, 40, 45, 48] },
active: { value: 9640, fmt: "int", delta: 3.2, dir: "up", spark: [88, 90, 91, 89, 93, 92, 95, 96] },
churn: { value: 1.9, fmt: "pct", delta: 0.4, dir: "down", spark: [26, 25, 24, 25, 23, 22, 20, 19] },
nps: { value: 62, fmt: "raw", delta: 5, dir: "up", deltaUnit: " pts", spark: [52, 54, 55, 57, 56, 59, 60, 62] }
},
mix: [
{ label: "Organic", value: 41, color: "#5b5bf0" },
{ label: "Referral", value: 27, color: "#00b4a6" },
{ label: "Paid", value: 22, color: "#d98a2b" },
{ label: "Social", value: 10, color: "#3a3ab8" }
]
},
week: {
caption: "Comparing this week against last week.",
compare: "vs last week",
hint: "This week",
stats: {
signups: { value: 8910, fmt: "int", delta: 5.7, dir: "up", spark: [62, 65, 64, 70, 72, 69, 75, 80] },
revenue: { value: 326400, fmt: "money", delta: 9.3, dir: "up", spark: [240, 255, 250, 270, 285, 280, 300, 326] },
active: { value: 41200, fmt: "int", delta: 2.1, dir: "up", spark: [380, 392, 388, 401, 397, 405, 410, 412] },
churn: { value: 2.3, fmt: "pct", delta: 0.6, dir: "up", spark: [18, 19, 20, 19, 21, 22, 22, 23] },
nps: { value: 59, fmt: "raw", delta: 2, dir: "down", deltaUnit: " pts", spark: [63, 62, 61, 62, 60, 61, 60, 59] }
},
mix: [
{ label: "Organic", value: 38, color: "#5b5bf0" },
{ label: "Referral", value: 24, color: "#00b4a6" },
{ label: "Paid", value: 28, color: "#d98a2b" },
{ label: "Social", value: 10, color: "#3a3ab8" }
]
},
month: {
caption: "Comparing this month against last month.",
compare: "vs last month",
hint: "This month",
stats: {
signups: { value: 38420, fmt: "int", delta: 14.6, dir: "up", spark: [240, 260, 255, 290, 310, 300, 340, 384] },
revenue: { value: 1410000, fmt: "money", delta: 18.2, dir: "up", spark: [980, 1040, 1010, 1120, 1180, 1240, 1330, 1410] },
active: { value: 132800, fmt: "int", delta: 6.5, dir: "up", spark: [1080, 1120, 1150, 1190, 1210, 1260, 1300, 1328] },
churn: { value: 1.6, fmt: "pct", delta: 0.9, dir: "down", spark: [28, 26, 27, 24, 23, 21, 18, 16] },
nps: { value: 64, fmt: "raw", delta: 6, dir: "up", deltaUnit: " pts", spark: [54, 55, 57, 58, 60, 61, 63, 64] }
},
mix: [
{ label: "Organic", value: 44, color: "#5b5bf0" },
{ label: "Referral", value: 25, color: "#00b4a6" },
{ label: "Paid", value: 21, color: "#d98a2b" },
{ label: "Social", value: 10, color: "#3a3ab8" }
]
}
};
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var current = "today";
var liveOn = true;
var liveTimer = null;
// ---- Formatters ---------------------------------------------------------
function format(value, fmt) {
if (fmt === "money") {
if (value >= 1000000) return "$" + (value / 1000000).toFixed(2) + "M";
if (value >= 1000) return "$" + (value / 1000).toFixed(1) + "k";
return "$" + Math.round(value);
}
if (fmt === "pct") return value.toFixed(1) + "%";
if (fmt === "int") return Math.round(value).toLocaleString("en-US");
return Math.round(value).toString();
}
// ---- Count-up animation -------------------------------------------------
function countUp(el, from, to, fmt) {
if (reduceMotion) {
el.textContent = format(to, fmt);
return;
}
var start = performance.now();
var dur = 650;
function tick(now) {
var t = Math.min(1, (now - start) / dur);
var eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
el.textContent = format(from + (to - from) * eased, fmt);
if (t < 1) requestAnimationFrame(tick);
else el.textContent = format(to, fmt);
}
requestAnimationFrame(tick);
}
// ---- Sparkline geometry -------------------------------------------------
function sparkPoints(series) {
var w = 120, h = 36, pad = 3;
var min = Math.min.apply(null, series);
var max = Math.max.apply(null, series);
var range = max - min || 1;
var step = (w - pad * 2) / (series.length - 1);
return series.map(function (v, i) {
var x = pad + i * step;
var y = h - pad - ((v - min) / range) * (h - pad * 2);
return x.toFixed(1) + "," + y.toFixed(1);
});
}
function drawSpark(card, series) {
var svg = card.querySelector("[data-spark]");
if (!svg) return;
var pts = sparkPoints(series);
var line = svg.querySelector(".spark__line");
var fill = svg.querySelector(".spark__fill");
line.setAttribute("points", pts.join(" "));
// Close the polygon along the baseline for the soft fill.
var first = pts[0].split(",")[0];
var last = pts[pts.length - 1].split(",")[0];
fill.setAttribute("points", first + ",33 " + pts.join(" ") + " " + last + ",33");
}
// ---- Render one card ----------------------------------------------------
function renderCard(card, stat, compare, animate) {
var key = card.getAttribute("data-key");
var valEl = card.querySelector("[data-value]");
var deltaEl = card.querySelector("[data-delta]");
var deltaVal = card.querySelector("[data-delta-val]");
var caption = card.querySelector("[data-caption]");
var prev = parseFloat((valEl.dataset.raw || stat.value));
if (animate) countUp(valEl, prev, stat.value, stat.fmt);
else valEl.textContent = format(stat.value, stat.fmt);
valEl.dataset.raw = stat.value;
var unit = stat.deltaUnit || "%";
deltaVal.textContent = (stat.fmt === "raw" ? Math.round(stat.delta) : stat.delta.toFixed(1)) + unit;
var goodDirection = key === "churn" ? "down" : "up";
var isGood = stat.dir === goodDirection;
deltaEl.classList.toggle("delta--up", stat.dir === "up");
deltaEl.classList.toggle("delta--down", stat.dir === "down");
deltaEl.querySelector(".delta__arrow").textContent = stat.dir === "up" ? "▲" : "▼";
// Sparkline recolors red only when the movement is genuinely bad.
card.classList.toggle("is-neg", !isGood);
caption.textContent = compare;
drawSpark(card, stat.spark);
if (animate && !reduceMotion) {
card.classList.remove("is-flash");
void card.offsetWidth; // restart animation
card.classList.add("is-flash");
}
}
// ---- Channel mix bar ----------------------------------------------------
function renderMix(mix, hint) {
var bar = document.getElementById("mixChart");
var legend = document.getElementById("mixLegend");
document.getElementById("mixHint").textContent = hint;
bar.innerHTML = "";
legend.innerHTML = "";
mix.forEach(function (m) {
var seg = document.createElement("div");
seg.className = "mix__seg";
seg.style.background = m.color;
seg.style.flex = "0 0 " + m.value + "%";
seg.title = m.label + " · " + m.value + "%";
bar.appendChild(seg);
var li = document.createElement("li");
li.innerHTML =
'<span class="swatch" style="background:' + m.color + '"></span>' +
m.label + " <strong>" + m.value + "%</strong>";
legend.appendChild(li);
});
}
// ---- Render a full period -----------------------------------------------
function render(period, animate) {
var d = DATA[period];
document.getElementById("periodCaption").textContent = d.caption;
document.querySelectorAll(".stat-card").forEach(function (card) {
var key = card.getAttribute("data-key");
if (d.stats[key]) renderCard(card, d.stats[key], d.compare, animate);
});
renderMix(d.mix, d.hint);
}
// ---- Period tabs --------------------------------------------------------
var tabs = Array.prototype.slice.call(document.querySelectorAll(".period-btn"));
tabs.forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.dataset.period === current) return;
tabs.forEach(function (b) {
var on = b === btn;
b.classList.toggle("is-active", on);
b.setAttribute("aria-selected", on ? "true" : "false");
});
current = btn.dataset.period;
render(current, true);
toast("Showing " + btn.textContent.trim().toLowerCase() + " metrics");
});
});
// Arrow-key navigation between tabs (roving).
document.querySelector(".period").addEventListener("keydown", function (e) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
var idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
e.preventDefault();
var next = e.key === "ArrowRight" ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
tabs[next].focus();
tabs[next].click();
});
// ---- Live tick: nudge the "today" current values --------------------------
function liveTick() {
if (current !== "today") return;
var stats = DATA.today.stats;
Object.keys(stats).forEach(function (key) {
var s = stats[key];
var jitter = s.fmt === "money" ? (Math.random() * 60 - 20) : (Math.random() * 6 - 2);
if (s.fmt === "pct") jitter = (Math.random() * 0.1 - 0.04);
s.value = Math.max(0, s.value + jitter);
// shift the spark series to keep it lively
var lastPt = s.spark[s.spark.length - 1];
s.spark = s.spark.slice(1).concat([Math.max(1, lastPt + (Math.random() * 6 - 2))]);
});
render("today", true);
}
function startLive() {
stopLive();
liveTimer = setInterval(liveTick, 4200);
}
function stopLive() {
if (liveTimer) clearInterval(liveTimer);
liveTimer = null;
}
var liveBtn = document.getElementById("liveToggle");
liveBtn.addEventListener("click", function () {
liveOn = !liveOn;
liveBtn.classList.toggle("is-on", liveOn);
liveBtn.setAttribute("aria-pressed", liveOn ? "true" : "false");
if (liveOn) {
startLive();
toast("Live updates on");
} else {
stopLive();
toast("Live updates paused");
}
});
// ---- Card menu (demo affordance) ---------------------------------------
document.querySelectorAll(".stat-card__menu").forEach(function (m) {
m.addEventListener("click", function () {
var label = m.closest(".stat-card").querySelector(".stat-card__label").textContent;
toast(label + ": exported to CSV");
});
});
// ---- Toast --------------------------------------------------------------
var toastEl = document.getElementById("toast");
var toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-show");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-show");
}, 2200);
}
// ---- Init ---------------------------------------------------------------
render("today", false);
if (liveOn) startLive();
// Pause live updates when the tab is hidden to save cycles.
document.addEventListener("visibilitychange", function () {
if (document.hidden) stopLive();
else if (liveOn) startLive();
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meridian Labs — KPI stat row</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>
<main class="page" role="main">
<header class="page-head">
<div class="head-text">
<p class="eyebrow">Meridian Labs · Growth overview</p>
<h1>Key metrics</h1>
<p class="sub">A live snapshot of acquisition, revenue and engagement for a fictional SaaS.</p>
</div>
<div class="head-controls">
<div class="period" role="tablist" aria-label="Reporting period">
<button class="period-btn is-active" role="tab" aria-selected="true" data-period="today">Today</button>
<button class="period-btn" role="tab" aria-selected="false" data-period="week">Week</button>
<button class="period-btn" role="tab" aria-selected="false" data-period="month">Month</button>
</div>
<button class="live-toggle is-on" id="liveToggle" aria-pressed="true">
<span class="dot" aria-hidden="true"></span> Live
</button>
</div>
</header>
<p class="period-caption" id="periodCaption" aria-live="polite">Comparing today against yesterday.</p>
<!-- KPI stat row -->
<section class="stat-row" aria-label="Key performance indicators" id="statRow">
<!-- Cards are populated/animated by script.js; markup is the static baseline (Today). -->
<article class="stat-card" data-key="signups">
<header class="stat-card__head">
<span class="stat-card__label">New sign-ups</span>
<button class="stat-card__menu" aria-label="New sign-ups options">⋯</button>
</header>
<div class="stat-card__value" data-value>1,284</div>
<div class="stat-card__foot">
<span class="delta delta--up" data-delta>
<span class="delta__arrow" aria-hidden="true">▲</span><span data-delta-val>8.4%</span>
</span>
<span class="stat-card__caption" data-caption>vs yesterday</span>
</div>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true" data-spark>
<polyline class="spark__line" points="" />
<polygon class="spark__fill" points="" />
</svg>
</article>
<article class="stat-card" data-key="revenue">
<header class="stat-card__head">
<span class="stat-card__label">Revenue</span>
<button class="stat-card__menu" aria-label="Revenue options">⋯</button>
</header>
<div class="stat-card__value" data-value>$48.2k</div>
<div class="stat-card__foot">
<span class="delta delta--up" data-delta>
<span class="delta__arrow" aria-hidden="true">▲</span><span data-delta-val>12.1%</span>
</span>
<span class="stat-card__caption" data-caption>vs yesterday</span>
</div>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true" data-spark>
<polyline class="spark__line" points="" />
<polygon class="spark__fill" points="" />
</svg>
</article>
<article class="stat-card" data-key="active">
<header class="stat-card__head">
<span class="stat-card__label">Active users</span>
<button class="stat-card__menu" aria-label="Active users options">⋯</button>
</header>
<div class="stat-card__value" data-value>9,640</div>
<div class="stat-card__foot">
<span class="delta delta--up" data-delta>
<span class="delta__arrow" aria-hidden="true">▲</span><span data-delta-val>3.2%</span>
</span>
<span class="stat-card__caption" data-caption>vs yesterday</span>
</div>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true" data-spark>
<polyline class="spark__line" points="" />
<polygon class="spark__fill" points="" />
</svg>
</article>
<article class="stat-card" data-key="churn">
<header class="stat-card__head">
<span class="stat-card__label">Churn rate</span>
<button class="stat-card__menu" aria-label="Churn rate options">⋯</button>
</header>
<div class="stat-card__value" data-value>1.9%</div>
<div class="stat-card__foot">
<span class="delta delta--down" data-delta>
<span class="delta__arrow" aria-hidden="true">▼</span><span data-delta-val>0.4%</span>
</span>
<span class="stat-card__caption" data-caption>vs yesterday</span>
</div>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true" data-spark>
<polyline class="spark__line" points="" />
<polygon class="spark__fill" points="" />
</svg>
</article>
<article class="stat-card" data-key="nps">
<header class="stat-card__head">
<span class="stat-card__label">NPS</span>
<button class="stat-card__menu" aria-label="NPS options">⋯</button>
</header>
<div class="stat-card__value" data-value>62</div>
<div class="stat-card__foot">
<span class="delta delta--up" data-delta>
<span class="delta__arrow" aria-hidden="true">▲</span><span data-delta-val>5 pts</span>
</span>
<span class="stat-card__caption" data-caption>vs yesterday</span>
</div>
<svg class="spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true" data-spark>
<polyline class="spark__line" points="" />
<polygon class="spark__fill" points="" />
</svg>
</article>
</section>
<!-- Supporting widget: composition bar to give the strip a data-dense companion -->
<section class="panel" aria-labelledby="mixHead">
<header class="panel__head">
<h2 id="mixHead">Sign-ups by channel</h2>
<span class="panel__hint" id="mixHint">Today</span>
</header>
<div class="mix" id="mixChart" aria-hidden="true"></div>
<ul class="legend" id="mixLegend"></ul>
</section>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</main>
<script src="script.js"></script>
</body>
</html>KPI / stat row (with trends)
A clean, responsive row of five KPI cards for Meridian Labs, a fictional SaaS. Each card shows a label, a large tabular-number value, a pill-shaped delta with an up or down arrow coloured with --ok or --danger, a “vs last period” caption, and a tiny inline-SVG sparkline whose shape and colour follow the trend. Churn is treated as an inverse metric, so a falling value reads as good (green) while a rising one recolours its delta and sparkline red — the row encodes meaning, not just direction.
The primary interaction is the Today / Week / Month period toggle. Selecting a range swaps the entire dataset: every value animates with an ease-out count-up to its new figure, deltas recompute and recolour, captions update to the matching comparison, and each sparkline redraws to fit its new series. A live toggle nudges the current-day figures every few seconds with a subtle flash so the strip feels alive, the card menus (⋯) fire a toast, and a stacked channel-mix bar beneath the row updates alongside the period for a data-dense companion widget.
Everything is built with semantic landmarks, aria-selected tabs with roving arrow-key navigation, an aria-pressed live toggle, visible focus rings and WCAG-AA contrast. The grid flows from five columns to three, two, then a single column by ~360px, animations respect prefers-reduced-motion, and live updates pause when the tab is hidden. No frameworks, no chart libraries, no build step.