Skip to main content
Showcases
Muze Showcase · 2026-06-11

KPI Cards · Six Flavours

One SaaS metrics dataset, six ready-to-use scorecards — headline, goal ring, multi headline, multi-delta strip, drivers, and forecast.

Loading viz…

What you’re looking at

Six ready-to-use KPI scorecards, each built from one synthetic SaaS dataset — daily revenue, targets, orders, active users, and support metrics for three product lines across the first half of 2026. Switch tabs to move between them:

  • Headline + Delta: the big number with a month-over-month change chip.
  • Goal Ring: revenue against a target, drawn as a progress ring coloured by how close you are.
  • Multi Headline: three focal numbers side by side.
  • Multi-Delta Strip: one value compared against several past windows at once.
  • Drivers: which product lines moved the number, as contributor chips.
  • Forecast: monthly actuals extended by a Holt linear-trend projection with a confidence band.

How it works

Every number on these cards comes straight from Muze’s DataModel: select(...) scopes the rows by date and groupBy([]) rolls a measure up to a single value — the same query engine that powers every Muze chart, here driving plain HTML. Each card then computes its figures with a few small helpers and writes them into a fixed HTML scaffold by setting textContent on slots like .kpi-value and .kpi-delta-text.

The HTML scaffold and CSS are shared across all six cards and never change — only the JavaScript differs, and each example carries just the helpers it needs. Copy the three artifacts below into the matching Muze Studio panels, load the CSV into a ThoughtSpot Worksheet, and edit the hardcoded measures, dates, and labels to fit your own data.

Take it with you

The JavaScript below is the complete, self-contained code for the selected example (“Headline + Delta”) — switch tabs above to see a different one. The HTML and CSS are shared by all six. Paste each piece into the matching Muze Studio panel.

JavaScript

Self-contained: reads the DataModel, computes the values with inlined helpers, and writes them into the scaffold. Edit the measures, date ranges, and labels to fit your data — there's no spec or shared library to learn.

Preview
const { muze, getDataFromSearchQuery, formatters, events } = viz;
const dm = getDataFromSearchQuery();
const card = document.querySelector("#chart .kpi-card");
// Compact USD formatter — $1.3M, $12.4K.
const fmt = new formatters.NumberFormatter("currency", { currency: "USD", unit: "auto", decimalDigits: 1 });
const money = (n) => (n == null ? "—" : fmt.format(n));

// Sum a measure between two dates (inclusive). The column's default
// aggregation applies — "revenue" sums. "date" is an epoch timestamp.
function aggregate(field, fromISO, toISO) {
  const C = muze.DataModel.ComparisonOperators;
  const L = muze.DataModel.LogicalOperators;
  const scoped = dm.select({
    conditions: [
      { field: "date", value: Date.parse(fromISO + "T00:00:00Z"), operator: C.GREATER_THAN_EQUAL },
      { field: "date", value: Date.parse(toISO + "T23:59:59Z"),   operator: C.LESS_THAN_EQUAL },
    ],
    operator: L.AND,
  });
  const out = scoped.groupBy([]).getData();
  if (!out.data.length) return null;
  return Number(out.data[0][out.schema.findIndex((c) => c.name === field)]);
}

const thisMonth = aggregate("revenue", "2026-06-01", "2026-06-30");
const lastMonth = aggregate("revenue", "2026-05-01", "2026-05-31");
const change = (thisMonth - lastMonth) / Math.abs(lastMonth);

// Title.
card.querySelector(".kpi-title").textContent = "Revenue This Month vs Last Month";

// Headline value — clone the headline template into the primary row.
const headline = card
  .querySelector("template.kpi-primary-tmpl-headline")
  .content.firstElementChild.cloneNode(true);
card.querySelector(".kpi-primary-row").appendChild(headline);
headline.querySelector(".kpi-value").textContent = money(thisMonth);

// Delta chip — green when up (good for revenue), red when down.
card.classList.add("kpi-card--has-delta");
const delta = card.querySelector(".kpi-row-delta");
const up = change >= 0;
const color = up ? "#15803d" : "#b91c1c";
delta.querySelector(".kpi-delta-arrow").textContent = up ? "▲" : "▼";
delta.querySelector(".kpi-delta-text").textContent = (Math.abs(change) * 100).toFixed(1) + "%";
delta.querySelector(".kpi-delta").style.background = color + "1A";
delta.querySelector(".kpi-delta").style.color = color;
delta.querySelector(".kpi-sublabel").textContent = "vs last month";

events.emitRenderCompletedEvent();

CSS

The shared card chassis — design tokens on .kpi-card plus every layout primitive. Identical for all six cards; theme via the --kpi-* custom properties.

Preview
/* ──────────────────────────────────────────────────────────────────────
   KPI runtime stylesheet — embedded verbatim, deterministic layout.
   Design tokens live on '.kpi-card' so any consumer can theme by
   overriding the relevant '--kpi-*' custom property.
   ──────────────────────────────────────────────────────────────────────*/

.kpi-card {
  /* Card chrome */
  --kpi-card-bg: #ffffff;
  --kpi-card-border: #e5e7eb;
  --kpi-card-radius: 14px;
  --kpi-card-padding: 22px 24px;
  --kpi-card-gap: 10px;
  --kpi-card-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 6px 24px rgba(15,23,42,0.06);

  /* Typography */
  --kpi-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Helvetica, Arial, sans-serif;
  --kpi-title-color: #6b7280;
  --kpi-subtitle-color: #94a3b8;
  --kpi-value-color: #0f172a;
  --kpi-muted-color: #94a3b8;

  /* Responsive headline scales — separate for bare tile vs in-ring.
     The in-ring scale is tuned to feel proportional to the ring's
     own responsive range (clamp 220-360px below): at the small ring
     the headline reads ~28px, at the 360px ring it sits at ~44px so
     the focal number visually matches the ring's size. */
  --kpi-headline-size: clamp(36px, 6vw, 52px);
  --kpi-headline-size-ring: clamp(28px, 3.6vw, 44px);

  /* Ring */
  --kpi-ring-size: clamp(220px, 50%, 360px);
  --kpi-ring-track: #f1f5f9;
  --kpi-ring-text-gap: 6px;
  --kpi-accent: #2563eb;

  /* Strip */
  --kpi-strip-min-cell: 56px;
  --kpi-strip-gap: 1px;

  /* Apply chrome tokens. */
  font-family: var(--kpi-font-family);
  background: var(--kpi-card-bg);
  border: 1px solid var(--kpi-card-border);
  border-radius: var(--kpi-card-radius);
  box-shadow: var(--kpi-card-shadow);
  padding: var(--kpi-card-padding);
  display: flex; flex-direction: column; gap: var(--kpi-card-gap);
  position: relative;
}

.kpi-title    { font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--kpi-title-color); }
.kpi-subtitle { font-size: 11px; color: var(--kpi-subtitle-color); }
.kpi-value    { font-size: var(--kpi-headline-size); font-weight: 700; line-height: 1.05; color: var(--kpi-value-color); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }

/* Primary row: a flex container the renderer fills by cloning the
   matching per-kind <template> for each entry in spec.config.kpi.primary.
   When the spec has ONE primary, one cell renders full-width; when
   it has many, the row distributes them with equal flex shares. The
   first-child / nth-of-type approach keeps the layout responsive
   without hardcoding cell widths. */
.kpi-primary-row                          { display: flex; flex-direction: row; align-items: flex-start; gap: 24px; }
.kpi-primary-row .kpi-primary             { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.kpi-primary-row .kpi-primary-goal-ring   { display: block; }
.kpi-primary-row .kpi-primary-goal-bar    { display: flex; flex-direction: column; gap: 10px; }

/* Per-cell label appears above the focal element when an array-form
   primary cell wants a small caption (e.g. "MTD" / "Last Month").
   Hidden when empty so single-primary cards don't reserve space. */
.kpi-primary-label                        { font-size: 11px; color: var(--kpi-title-color); font-weight: 600; }
.kpi-primary-label:empty                  { display: none; }

/* When the row carries MORE than one primary cell, the bare headline
   value would overflow at full size — scale it down. The selector
   ':has(.kpi-primary + .kpi-primary)' matches when there are at least
   two primary siblings in the row. */
.kpi-primary-row:has(.kpi-primary + .kpi-primary) .kpi-value { font-size: clamp(20px, 2.5vw, 32px); }

/* Row + trend reveal rules unchanged. */

.kpi-rows .kpi-row { display: none; }
.kpi-card--has-delta             .kpi-row-delta             { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
.kpi-card--has-multi-delta-strip .kpi-row-multi-delta-strip { display: block; }
.kpi-card--has-drivers           .kpi-row-drivers           { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; margin-top: 6px; }
.kpi-card--has-narrative         .kpi-row-narrative         { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
.kpi-card--has-sublabel          .kpi-row-sublabel          { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
.kpi-card--has-status-chip       .kpi-row-status-chip       { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 2px; }

/* Trend mount stays out of layout flow until activated — otherwise it
   reserves a 70px-tall blank box at the bottom of every card. */
.kpi-trend { display: none; }
.kpi-card--has-trend .kpi-trend { display: block; }

/* Collapse always-rendered text wrappers when they're empty so the
   card gap doesn't reserve space for invisible titles / subtitles. */
.kpi-title:empty, .kpi-subtitle:empty { display: none; }

.kpi-delta { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.kpi-sublabel, .kpi-sublabel-text { font-size: 12px; color: var(--kpi-muted-color); }

/* Inline multi-delta strip: bold colored value + muted "vs X" label,
   adjacent cells separated by a thin 1px divider. No outer border,
   no per-cell background — reads as inline text, not as a table.
   Wraps gracefully on narrow cards. */
.kpi-strip          { width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 18px 22px; margin-top: 6px; }
.kpi-strip-cell     { display: inline-flex; align-items: baseline; gap: 8px; position: relative; padding-left: 22px; }
.kpi-strip-cell:first-child { padding-left: 0; }
.kpi-strip-cell:not(:first-child)::before { content: ""; position: absolute; left: 0; top: 8%; bottom: 8%; width: 1px; background: var(--kpi-card-border); }
.kpi-strip-period   { font-size: 12px; color: var(--kpi-muted-color); font-weight: 400; }
.kpi-strip-delta    { font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }

/* Drivers row: the section-header + chip-strip stacking comes from
   the has-drivers reveal rule above. The label mirrors '.kpi-title'
   (uppercase, tracked) so it reads as a secondary section title
   rather than a comparison label. */
.kpi-drivers-label { font-size: 10px; font-weight: 600; color: var(--kpi-title-color); letter-spacing: 0.08em; text-transform: uppercase; }
.kpi-drivers       { display: flex; gap: 6px; flex-wrap: wrap; }
.kpi-driver        { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 3px 8px; border-radius: 6px; background: #f8fafc; border: 1px solid #e5e7eb; }
.kpi-driver-dot    { width: 6px; height: 6px; border-radius: 50%; }
.kpi-driver-name   { font-weight: 600; color: var(--kpi-value-color); }
.kpi-driver-num    { font-variant-numeric: tabular-nums; font-weight: 600; }

.kpi-narrative      { background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%); border-left: 3px solid var(--kpi-accent); border-radius: 6px; padding: 10px 12px; font-size: 13px; line-height: 1.5; color: #1e293b; }
.kpi-narrative-tag  { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--kpi-accent); }
.kpi-narrative-body b { color: var(--kpi-accent); font-weight: 600; }

.kpi-status-chip { display: inline-flex; align-items: center; padding: 3px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }

/* Ring is responsive via aspect-ratio + clamp on width. */
.kpi-ring-wrap     { position: relative; width: var(--kpi-ring-size); aspect-ratio: 1; margin: 8px auto 4px; }
.kpi-ring-svg      { width: 100%; height: 100%; display: block; }
.kpi-ring-center   { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0 14%; text-align: center; gap: var(--kpi-ring-text-gap); }
/* Labels scale a notch with the ring so the proportions hold at both
   the 220px and 360px ends of the responsive range. */
.kpi-ring-pct-label    { font-size: clamp(12px, 1.4vw, 15px); font-weight: 600; letter-spacing: 0.06em; color: var(--kpi-title-color); text-transform: uppercase; }
.kpi-ring-target-label { font-size: clamp(12px, 1.3vw, 14px); color: var(--kpi-muted-color); }

/* In-ring headline gets a smaller scale than the bare-tile .kpi-value. */
.kpi-primary-goal-ring .kpi-value { font-size: var(--kpi-headline-size-ring); line-height: 1.1; }

/* Goal-centered layout: applied to .kpi-card by the goal-progress
   renderer so sibling rows center under the ring. */
.kpi-card--goal-centered .kpi-title,
.kpi-card--goal-centered .kpi-subtitle { text-align: center; }
.kpi-card--goal-centered .kpi-rows { align-items: center; }
.kpi-card--goal-centered .kpi-row { justify-content: center; }
.kpi-card--goal-centered .kpi-row-drivers { align-items: center; }
.kpi-card--goal-centered .kpi-drivers { justify-content: center; }
.kpi-card--goal-centered .kpi-sublabel-text { text-align: center; }


/* Goal-progress BAR variant. Layout is:
     row 1 (.kpi-goal-bar-head)  : headline value (left) | "of <target>" (right)
     row 2 (.kpi-goal-bar)       : full-width track + tone-coloured fill
     row 3 (.kpi-goal-bar-status): pill chip with "{pct}% — <status>"
   The chip's background + foreground colours are tinted off the band
   tone by the renderer (no per-tone CSS class needed). */
.kpi-goal-bar-head    { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.kpi-goal-bar-head .kpi-ring-target-label { margin-top: 0; }
.kpi-goal-bar         { width: 100%; height: 10px; border-radius: 999px; background: var(--kpi-ring-track); overflow: hidden; }
.kpi-goal-bar .fill   { height: 100%; width: 0%; transition: width 0.4s ease; }
.kpi-goal-bar-status  { align-self: flex-start; display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.kpi-goal-bar-status:empty { display: none; }

/* Trend canvas mount — display flips from none to block on the
   .kpi-card--has-trend reveal rule above. */
.kpi-trend            { width: 100%; height: 70px; margin-top: 4px; }

/* Forecast trend: dash the projected line. The renderer pins the
   projected series to the accent color with a '99' alpha suffix
   (~60% opacity); we target paths with that opacity to apply the
   dash pattern. Stroke colour matching in SVG attribute selectors
   is exact, so the renderer's color string must match this prefix.
   The two-line attribute selector form ('stroke$="99"') matches any
   accent color when the projected variant is built by appending the
   alpha suffix. */
.kpi-trend svg path[stroke$="99"] { stroke-dasharray: 4 3; }

.kpi-pace             { position: absolute; top: 14px; right: 14px; background: var(--kpi-card-bg); border: 1px solid var(--kpi-card-border); border-radius: 8px; padding: 6px 10px; text-align: right; box-shadow: 0 1px 2px rgba(0,0,0,0.03); }
.kpi-pace-label       { font-size: 11px; font-weight: 600; color: var(--kpi-title-color); letter-spacing: 0.05em; }
.kpi-pace-value       { font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 2px; }

HTML

The shared card scaffold inside the #chart mount — the superset of slots every card can fill. Unused slots render as nothing, so the same HTML serves all six examples.

Preview
<div id="chart">
<div class="kpi-card" role="figure" aria-labelledby="kpi-title">
  <div class="kpi-title" id="kpi-title"></div>
  <div class="kpi-subtitle"></div>

  <!-- PRIMARY-ROW: empty container; renderer clones one of the
       per-kind <template>s below into here for each primary cell.
       A single primary fills one cell (full width); an array
       primary fills N cells side-by-side. -->
  <div class="kpi-primary-row"></div>

  <!-- Per-kind primary templates. The renderer clones the matching
       template per spec.layers[0].kpi.primary cell and appends the
       clone into .kpi-primary-row. Templates carry no per-card data;
       they only define the slot DOM each kind needs. -->
  <template class="kpi-primary-tmpl-headline">
    <div class="kpi-primary kpi-primary-headline">
      <div class="kpi-primary-label"></div>
      <div class="kpi-value"></div>
      <div class="kpi-pace" style="display:none;">
        <div class="kpi-pace-label"></div>
        <div class="kpi-pace-value"></div>
      </div>
    </div>
  </template>

  <template class="kpi-primary-tmpl-goal-ring">
    <div class="kpi-primary kpi-primary-goal-ring">
      <div class="kpi-primary-label"></div>
      <div class="kpi-ring-wrap">
        <svg class="kpi-ring-svg" viewBox="0 0 100 100">
          <circle cx="50" cy="50" r="42" fill="none" stroke="#f1f5f9" stroke-width="8"/>
          <circle class="kpi-ring-progress" cx="50" cy="50" r="42" fill="none" stroke-width="8" stroke-linecap="round" transform="rotate(-90 50 50)"/>
        </svg>
        <div class="kpi-ring-center">
          <div class="kpi-ring-pct-label"></div>
          <div class="kpi-value"></div>
          <div class="kpi-ring-target-label"></div>
        </div>
      </div>
    </div>
  </template>

  <template class="kpi-primary-tmpl-goal-bar">
    <div class="kpi-primary kpi-primary-goal-bar">
      <div class="kpi-primary-label"></div>
      <div class="kpi-goal-bar-head">
        <div class="kpi-value"></div>
        <div class="kpi-ring-target-label"></div>
      </div>
      <div class="kpi-goal-bar"><div class="fill"></div></div>
      <div class="kpi-goal-bar-status"></div>
    </div>
  </template>

  <!-- ROWS: one container per row, in spec order -->
  <div class="kpi-rows">
    <!-- delta row -->
    <div class="kpi-row kpi-row-delta">
      <span class="kpi-delta"><span class="kpi-delta-arrow"></span><span class="kpi-delta-text"></span></span>
      <span class="kpi-sublabel"></span>
    </div>
    <!-- multi-delta-strip row -->
    <div class="kpi-row kpi-row-multi-delta-strip">
      <div class="kpi-strip"></div>
    </div>
    <!-- drivers row: section label + chip strip -->
    <div class="kpi-row kpi-row-drivers">
      <div class="kpi-drivers-label"></div>
      <div class="kpi-drivers"></div>
    </div>
    <!-- narrative row -->
    <div class="kpi-row kpi-row-narrative">
      <div class="kpi-narrative"><div class="kpi-narrative-body"></div></div>
    </div>
    <!-- sublabel row -->
    <div class="kpi-row kpi-row-sublabel">
      <div class="kpi-sublabel-text"></div>
    </div>
    <!-- status-chip row -->
    <div class="kpi-row kpi-row-status-chip">
      <span class="kpi-status-chip"></span>
    </div>
  </div>

  <!-- TREND mount -->
  <div class="kpi-trend" id="kpi-trend"></div>
</div>
</div>

Dataset (CSV)

Synthetic SaaS metrics for the cards above — daily grain, Jan–Jun 2026, three product lines. Load it into a Muze Studio Worksheet and create an Answer with it.