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.

data/dashboard-kpis.astro
---
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>