Analytics Dashboard KPIs
A polished analytics fragment: KPI tiles whose numbers count up when scrolled into view, a labelled CSS bar chart, and an SVG sparkline that draws itself in. Deltas use arrows + sign + colour, never colour alone.
- IntersectionObserver
- requestAnimationFrame count-up
- SVG stroke-dashoffset
- CSS bars
Source
A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.
---
export const meta = {
title: "Analytics Dashboard KPIs",
tags: ["data", "dashboard", "dataviz", "chart"],
description:
"A polished analytics fragment: KPI tiles whose numbers count up when scrolled into view, a labelled CSS bar chart, and an SVG sparkline that draws itself in. Deltas use arrows + sign + colour, never colour alone.",
tech: ["IntersectionObserver", "requestAnimationFrame count-up", "SVG stroke-dashoffset", "CSS bars"],
interactive: true,
height: 520,
};
const kpis = [
{ label: "Revenue", value: 128540, prefix: "$", suffix: "", delta: 12.4, dir: "up", note: "vs last month" },
{ label: "Active users", value: 8420, prefix: "", suffix: "", delta: 6.1, dir: "up", note: "7-day avg" },
{ label: "Conversion", value: 3.8, prefix: "", suffix: "%", delta: 0.4, dir: "down", note: "checkout" },
{ label: "Avg. session", value: 4.6, prefix: "", suffix: "m", delta: 8.9, dir: "up", note: "engaged time" },
];
// weekly signups for the bar chart
const bars = [
{ d: "Mon", v: 42 }, { d: "Tue", v: 58 }, { d: "Wed", v: 51 },
{ d: "Thu", v: 73 }, { d: "Fri", v: 66 }, { d: "Sat", v: 88 }, { d: "Sun", v: 79 },
];
const barMax = 100;
// sparkline points (0..100), mapped to a 260x64 viewBox path
const spark = [22, 28, 24, 40, 36, 52, 48, 63, 58, 74, 70, 90];
const sw = 260, sh = 64, pad = 4;
const sparkPts = spark.map((v, i) => {
const x = pad + (i / (spark.length - 1)) * (sw - pad * 2);
const y = sh - pad - (v / 100) * (sh - pad * 2);
return [x, y];
});
const sparkLine = sparkPts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
const sparkArea = `${sparkLine} L${(sw - pad).toFixed(1)},${sh - pad} L${pad},${sh - pad} Z`;
---
<section class="kdash" data-kdash aria-label="Analytics dashboard">
<header class="kdash__head">
<div>
<h2 class="kdash__title">Overview</h2>
<p class="kdash__sub">Last 30 days · updated just now</p>
</div>
<span class="kdash__live"><span class="kdash__dot" aria-hidden="true"></span>Live</span>
</header>
<div class="kdash__tiles">
{kpis.map((k) => (
<article class="ktile">
<p class="ktile__label">{k.label}</p>
<p
class="ktile__value"
data-count
data-to={k.value}
data-prefix={k.prefix}
data-suffix={k.suffix}
data-decimals={Number.isInteger(k.value) ? "0" : "1"}
>{k.prefix}{k.value.toLocaleString("en-US")}{k.suffix}</p>
<p class="ktile__foot">
<span class={`chip chip--${k.dir}`} aria-label={`${k.dir === "up" ? "Up" : "Down"} ${k.delta} percent`}>
<span class="chip__arrow" aria-hidden="true">{k.dir === "up" ? "▲" : "▼"}</span>
{k.dir === "up" ? "+" : "−"}{k.delta}%
</span>
<span class="ktile__note">{k.note}</span>
</p>
</article>
))}
</div>
<div class="kdash__charts">
<figure class="panel">
<figcaption class="panel__head">
<span class="panel__title">Signups this week</span>
<span class="panel__meta">{bars.reduce((s, b) => s + b.v, 0)} total</span>
</figcaption>
<div class="bars" role="img" aria-label="Bar chart of signups Monday to Sunday, peaking Saturday.">
{bars.map((b) => (
<div class="bar" style={`--h:${(b.v / barMax) * 100}%`}>
<span class="bar__fill"></span>
<span class="bar__d">{b.d}</span>
</div>
))}
</div>
</figure>
<figure class="panel">
<figcaption class="panel__head">
<span class="panel__title">Traffic trend</span>
<span class="panel__meta"><span class="chip chip--up chip--sm"><span aria-hidden="true">▲</span>+18%</span></span>
</figcaption>
<svg class="spark" viewBox={`0 0 ${sw} ${sh}`} preserveAspectRatio="none"
role="img" aria-label="Line chart of traffic rising steadily over the period.">
<defs>
<linearGradient id="kdash-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--spectrum-3, #45d3e8)" stop-opacity="0.35" />
<stop offset="1" stop-color="var(--spectrum-3, #45d3e8)" stop-opacity="0" />
</linearGradient>
<linearGradient id="kdash-stroke" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="var(--spectrum-2, #5b8def)" />
<stop offset="1" stop-color="var(--spectrum-3, #45d3e8)" />
</linearGradient>
</defs>
<path class="spark__area" d={sparkArea} fill="url(#kdash-fill)" />
<path class="spark__line" d={sparkLine} fill="none" stroke="url(#kdash-stroke)"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" pathLength="1" />
</svg>
</figure>
</div>
</section>
<style>
.kdash {
--pos: var(--spectrum-4, #4ade80);
--neg: var(--spectrum-6, #f472b6);
display: grid; gap: 1.1rem; padding: clamp(1rem, 0.6rem + 1.6vw, 1.75rem);
min-height: 520px; align-content: start;
background: var(--bg-sunk, #0b0c15); color: var(--ink, #f2f2f7);
font-family: var(--font-sans, system-ui, sans-serif);
}
.kdash__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.kdash__title { margin: 0; font-size: var(--step-2, 1.5rem); letter-spacing: -0.02em; }
.kdash__sub { margin: 0.15rem 0 0; color: var(--ink-muted, #b7b9c9); font-size: var(--step--1, 0.85rem); }
.kdash__live {
display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.78rem; font-weight: 600;
padding: 0.3rem 0.6rem; border-radius: var(--radius-round, 999px);
color: var(--ink, #fff); background: color-mix(in oklab, var(--pos) 14%, transparent);
border: 1px solid color-mix(in oklab, var(--pos) 40%, transparent);
}
.kdash__dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pos); box-shadow: 0 0 0 0 color-mix(in oklab, var(--pos) 60%, transparent); animation: kdash-pulse 2.4s ease-out infinite; }
@keyframes kdash-pulse { 70%, 100% { box-shadow: 0 0 0 7px transparent; } }
/* --- KPI tiles --------------------------------------------------------- */
.kdash__tiles { display: grid; gap: 0.9rem; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
.ktile {
display: grid; gap: 0.35rem; padding: 1rem 1.1rem; border-radius: var(--radius-m, 14px);
background: var(--surface, #14151f); border: 1px solid var(--border, #2a2c3a);
box-shadow: var(--shadow-s);
}
.ktile__label { margin: 0; font-size: 0.8rem; color: var(--ink-muted, #b7b9c9); font-weight: 600; letter-spacing: 0.02em; }
.ktile__value {
margin: 0; font-size: clamp(1.6rem, 1.2rem + 1.4vw, 2.2rem); font-weight: 750;
letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums;
color: var(--ink, #fff);
}
.ktile__foot { margin: 0.15rem 0 0; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.ktile__note { font-size: 0.75rem; color: var(--ink-faint, #8b8d9e); }
.chip {
display: inline-flex; align-items: center; gap: 0.28rem; font-size: 0.78rem; font-weight: 700;
padding: 0.2rem 0.5rem; border-radius: var(--radius-round, 999px); font-variant-numeric: tabular-nums;
border: 1px solid transparent;
}
.chip--sm { font-size: 0.72rem; padding: 0.12rem 0.42rem; }
.chip__arrow { font-size: 0.62em; }
.chip--up { color: var(--pos); background: color-mix(in oklab, var(--pos) 14%, transparent); border-color: color-mix(in oklab, var(--pos) 32%, transparent); }
.chip--down { color: var(--neg); background: color-mix(in oklab, var(--neg) 14%, transparent); border-color: color-mix(in oklab, var(--neg) 32%, transparent); }
/* --- charts ------------------------------------------------------------ */
.kdash__charts { display: grid; gap: 0.9rem; grid-template-columns: 1.3fr 1fr; }
@media (max-width: 640px) { .kdash__charts { grid-template-columns: 1fr; } }
.panel {
margin: 0; padding: 1rem 1.1rem 1.1rem; border-radius: var(--radius-m, 14px);
background: var(--surface, #14151f); border: 1px solid var(--border, #2a2c3a); display: grid; gap: 0.9rem;
}
.panel__head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.panel__title { font-size: 0.85rem; font-weight: 600; color: var(--ink, #fff); }
.panel__meta { font-size: 0.78rem; color: var(--ink-muted, #b7b9c9); }
.bars { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; align-items: end; gap: 0.5rem; height: 140px; }
.bar { display: grid; grid-template-rows: 1fr auto; align-items: end; gap: 0.4rem; height: 100%; text-align: center; }
.bar__fill {
display: block; height: var(--h); border-radius: 6px 6px 3px 3px; transform-origin: bottom; transform: scaleY(1);
background: linear-gradient(180deg, var(--spectrum-3, #45d3e8), var(--spectrum-2, #5b8def));
animation: bar-grow 0.9s var(--ease-out, cubic-bezier(0.22,1,0.36,1)) both;
}
.bar:nth-child(1) .bar__fill { animation-delay: 0.02s; }
.bar:nth-child(2) .bar__fill { animation-delay: 0.07s; }
.bar:nth-child(3) .bar__fill { animation-delay: 0.12s; }
.bar:nth-child(4) .bar__fill { animation-delay: 0.17s; }
.bar:nth-child(5) .bar__fill { animation-delay: 0.22s; }
.bar:nth-child(6) .bar__fill { animation-delay: 0.27s; }
.bar:nth-child(7) .bar__fill { animation-delay: 0.32s; }
.bar__d { font-size: 0.72rem; color: var(--ink-faint, #8b8d9e); }
@keyframes bar-grow { from { transform: scaleY(0); } }
.spark { width: 100%; height: 96px; display: block; overflow: visible; }
.spark__line { stroke-dasharray: 1; stroke-dashoffset: 1; animation: spark-draw 1.8s var(--ease-out, cubic-bezier(0.22,1,0.36,1)) 0.15s forwards; }
.spark__area { opacity: 0; animation: spark-fade 1s ease 1.1s forwards; }
@keyframes spark-draw { to { stroke-dashoffset: 0; } }
@keyframes spark-fade { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.kdash__dot,
.bar__fill { animation: none; }
.bar__fill { transform: scaleY(1); }
.spark__line { animation: none; stroke-dashoffset: 0; }
.spark__area { animation: none; opacity: 1; }
}
</style>
<script>
(function () {
function bind() {
document.querySelectorAll<HTMLElement>("[data-kdash]").forEach((root) => {
if (root.dataset.bound) return;
root.dataset.bound = "1";
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const nums = Array.from(root.querySelectorAll<HTMLElement>("[data-count]"));
const fmt = (n: number, dec: number) =>
n.toLocaleString("en-US", { minimumFractionDigits: dec, maximumFractionDigits: dec });
const run = (el: HTMLElement) => {
const to = parseFloat(el.dataset.to || "0");
const dec = parseInt(el.dataset.decimals || "0", 10);
const pre = el.dataset.prefix || "";
const suf = el.dataset.suffix || "";
const set = (v: number) => { el.textContent = pre + fmt(v, dec) + suf; };
if (reduce) { set(to); return; }
const dur = 1400, t0 = performance.now();
const tick = (now: number) => {
const p = Math.min(1, (now - t0) / dur);
const e = 1 - Math.pow(1 - p, 3); // easeOutCubic
set(to * e);
if (p < 1) requestAnimationFrame(tick); else set(to);
};
requestAnimationFrame(tick);
};
if (!("IntersectionObserver" in window)) { nums.forEach(run); return; }
const io = new IntersectionObserver((entries, obs) => {
entries.forEach((en) => {
if (!en.isIntersecting) return;
run(en.target as HTMLElement);
obs.unobserve(en.target);
});
}, { threshold: 0.4 });
nums.forEach((el) => io.observe(el));
});
}
document.addEventListener("astro:page-load", bind);
if (document.readyState !== "loading") bind();
})();
</script>