{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stat-cards",
  "type": "registry:block",
  "title": "Stat Cards",
  "description": "KPI cards whose figures count up on a spring, with delta badges that pop in and sparklines that draw themselves. Hover or drag across a card to scrub its trend: a dot rides the curve while the number, delta and date spring to that day.",
  "docs": "Stat Cards was added to public/vividsites/stat-cards.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/stat-cards",
  "categories": [
    "ui",
    "data-display"
  ],
  "files": [
    {
      "path": "components/vividsites/stat-cards.html",
      "type": "registry:file",
      "target": "public/vividsites/stat-cards.html",
      "content": "<!--\n  vs-stat-cards: KPI cards whose numbers count up on a spring when they scroll into view, with a delta\n  badge that pops in, and a sparkline that draws itself before its area fill fades up. Hover or drag\n  (touch) across a card to scrub its trend: a dot rides the actual curve, the big number springs to\n  that day's value, the delta re-computes against the start of the range and its arrow flips on a\n  spring, and the caption turns into the date. Leave and everything springs back to the latest value.\n\n  Markup (one <article> per card; its data lives in the JSON inside it, so you can swap it freely):\n    <div class=\"vs-stat-cards\" data-vs-stat-cards-spark=\"area\">\n      <article class=\"vs-stat-cards-card\" data-good=\"up\" data-prefix=\"$\" data-decimals=\"0\">\n        <h3 class=\"vs-stat-cards-label\">Net revenue</h3>\n        <p class=\"vs-stat-cards-value\">$482,190</p>          (shown as-is without JS)\n        <p class=\"vs-stat-cards-sub\">Last 30 days</p>\n        <script type=\"application/json\">{\"start\":\"2026-08-31\",\"values\":[428995, …, 482190]}</script>\n      </article>\n    </div>\n\n  Root attributes:\n    data-vs-stat-cards-spark   line | area | bars                       (area)\n    data-vs-stat-cards-theme   light | dark                             (light)\n  Card attributes:\n    data-good                  up | down  which direction is good: a falling churn rate is green (up)\n    data-prefix / data-suffix  text around the figure (\"$\", \"%\")\n    data-decimals              fixed decimals (0)\n    data-format                number | duration  (duration: values are seconds, shown \"1m 42s\")\n    data-delta                 pct | abs  (pct: % change; abs: difference in the card's own units)\n    data-delta-suffix          unit after an abs delta (\" pt\"); data-delta-decimals (1)\n    data-locale                Intl locale for separators and dates (en-GB)\n  JSON: {\"start\": ISO date of the first value, \"step\": days between values (1), \"values\": [...]}\n\n  Variables (set on .vs-stat-cards or a single card):\n    --vs-stat-cards-accent     the \"good\" colour and focus ring (#177245)\n    --vs-stat-cards-good       delta + sparkline when good     (the accent)\n    --vs-stat-cards-bad        delta + sparkline when bad      (#c0392b)\n  A card's sparkline takes the tone of its overall trend (good, bad or flat); the badge follows the scrub.\n    --vs-stat-cards-bg / -ink / -mute / -line   card surface, text, muted text, hairlines\n    --vs-stat-cards-radius     card corner radius              (18px)\n    --vs-stat-cards-size       big figure size                 (clamp 2.2–3.25rem)\n    --vs-stat-cards-spark-h    sparkline height                (84px)\n    --vs-stat-cards-pad        card side padding               (22px)\n    --vs-stat-cards-min        min card width in the auto grid (240px)\n    --vs-stat-cards-font       font family                     (inherit)\n\n  JS API: root.vsStatCards.replay() re-runs the entrance.\n  Events: \"vs-stat-cards:scrub\" on the card, detail {index, value, date} (index -1 when it resets).\n  Keyboard: cards are focusable; ←/→ scrub, Home/End jump, Esc resets. The scrubbed value is announced\n  through a polite live region; the sparkline itself is aria-hidden. prefers-reduced-motion: final\n  values are shown at once and scrubbing jumps instead of springing.\n-->\n<style>\n  .vs-stat-cards {\n    --vs-stat-cards-accent: #177245;\n    --vs-stat-cards-good: var(--vs-stat-cards-accent);\n    --vs-stat-cards-bad: #c0392b;\n    --vs-stat-cards-bg: #fcfbf8;\n    --vs-stat-cards-ink: #1b1c1a;\n    --vs-stat-cards-mute: rgba(27, 28, 26, .52);\n    --vs-stat-cards-line: rgba(58, 44, 18, .13);\n    --vs-stat-cards-shadow: 58, 44, 18;\n    --vs-stat-cards-hi: rgba(255, 255, 255, .95);\n    --vs-stat-cards-radius: 18px;\n    --vs-stat-cards-min: 240px;\n    --vs-stat-cards-spark-h: 84px;\n    --vs-stat-cards-pad: 22px;\n    --vs-stat-cards-size: clamp(2.2rem, 1.2rem + 2.6vw, 3.25rem);\n    --vs-stat-cards-font: inherit;\n    display: grid; gap: 16px;\n    grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--vs-stat-cards-min)), 1fr));\n    font-family: var(--vs-stat-cards-font);\n    color: var(--vs-stat-cards-ink);\n    -webkit-font-smoothing: antialiased;\n  }\n  .vs-stat-cards[data-vs-stat-cards-theme=\"dark\"] {\n    --vs-stat-cards-accent: #4cc38a;\n    --vs-stat-cards-bad: #ff6b5b;\n    --vs-stat-cards-bg: #17181a;\n    --vs-stat-cards-ink: #eeeeea;\n    --vs-stat-cards-mute: rgba(238, 238, 234, .5);\n    --vs-stat-cards-line: rgba(255, 255, 255, .1);\n    --vs-stat-cards-shadow: 0, 0, 0;\n    --vs-stat-cards-hi: rgba(255, 255, 255, .07);\n  }\n  .vs-stat-cards-card {\n    position: relative; isolation: isolate; min-width: 0;\n    display: flex; flex-direction: column;\n    padding: 20px var(--vs-stat-cards-pad) 0; border-radius: var(--vs-stat-cards-radius);\n    background: var(--vs-stat-cards-bg);\n    box-shadow:\n      0 0 0 .5px var(--vs-stat-cards-line),\n      inset 0 1px 0 var(--vs-stat-cards-hi),\n      0 1px 2px rgba(var(--vs-stat-cards-shadow), .05),\n      0 6px 14px -6px rgba(var(--vs-stat-cards-shadow), .09),\n      0 22px 40px -26px rgba(var(--vs-stat-cards-shadow), .16);\n    outline: 1.5px solid transparent; outline-offset: 8px; overflow: hidden; cursor: crosshair;\n    touch-action: pan-y; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n    transition: transform .5s cubic-bezier(.3, 1.4, .5, 1), outline-offset .45s cubic-bezier(.3, 1.4, .5, 1), outline-color .2s;\n  }\n  /* the deeper hover shadow lives on a pseudo so only its opacity animates */\n  .vs-stat-cards-card::before {\n    content: \"\"; position: absolute; inset: 0; z-index: -1; border-radius: inherit; pointer-events: none; opacity: 0;\n    box-shadow: 0 2px 4px rgba(var(--vs-stat-cards-shadow), .05), 0 14px 28px -10px rgba(var(--vs-stat-cards-shadow), .16), 0 34px 60px -30px rgba(var(--vs-stat-cards-shadow), .26);\n    transition: opacity .35s cubic-bezier(.2, 0, 0, 1);\n  }\n  @media (hover: hover) {\n    .vs-stat-cards-card:hover { transform: translateY(-2px); }\n    .vs-stat-cards-card:hover::before { opacity: 1; }\n  }\n  .vs-stat-cards-card.is-scrub { transform: translateY(-2px); }\n  .vs-stat-cards-card.is-scrub::before { opacity: 1; }\n  .vs-stat-cards-card:focus-visible { outline: 1.5px solid var(--vs-stat-cards-accent); outline-offset: 3px; }\n  .vs-stat-cards-card:active { transform: translateY(-1px) scale(.995); }\n\n  .vs-stat-cards-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 24px; }\n  .vs-stat-cards-label {\n    margin: 0; font-size: 11px; line-height: 1.2; font-weight: 500; letter-spacing: .12em; text-transform: uppercase;\n    color: var(--vs-stat-cards-mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n  }\n  .vs-stat-cards-value {\n    margin: 14px 0 0; font-size: var(--vs-stat-cards-size); line-height: 1; font-weight: 300;\n    letter-spacing: -.03em; white-space: nowrap;\n    font-variant-numeric: tabular-nums; font-feature-settings: \"tnum\" 1;\n  }\n  .vs-stat-cards-sub {\n    position: relative; margin: 10px 0 0; height: 1.3em; font-size: 13px; line-height: 1.3; font-weight: 400;\n    color: var(--vs-stat-cards-mute); font-variant-numeric: tabular-nums;\n  }\n  .vs-stat-cards-sub > span { position: absolute; left: 0; top: 0; white-space: nowrap; transition: opacity .28s cubic-bezier(.2, 0, 0, 1), transform .45s cubic-bezier(.2, 1.3, .4, 1); }\n  .vs-stat-cards-sub > .vs-stat-cards-date { opacity: 0; transform: translateY(6px); color: var(--vs-stat-cards-ink); font-weight: 500; }\n  .vs-stat-cards-card.is-scrub .vs-stat-cards-period { opacity: 0; transform: translateY(-6px); }\n  .vs-stat-cards-card.is-scrub .vs-stat-cards-date { opacity: 1; transform: none; }\n\n  /* delta badge: colour follows good/bad, arrow rotates on a spring */\n  .vs-stat-cards-delta {\n    --c: var(--vs-stat-cards-mute);\n    display: inline-flex; align-items: center; gap: 3px; flex: none;\n    height: 24px; padding: 0 8px 0 5px; border-radius: 999px;\n    font-size: 12px; font-weight: 500; letter-spacing: -.005em; font-variant-numeric: tabular-nums;\n    color: var(--c); background: color-mix(in srgb, var(--c) 10%, transparent);\n    box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--c) 26%, transparent);\n    transform-origin: 80% 50%; transition: color .3s, background-color .3s, box-shadow .3s;\n  }\n  .vs-stat-cards-delta[data-tone=\"good\"] { --c: var(--vs-stat-cards-good); }\n  .vs-stat-cards-delta[data-tone=\"bad\"] { --c: var(--vs-stat-cards-bad); }\n  .vs-stat-cards-delta svg { width: 14px; height: 14px; display: block; }\n\n  /* sparkline: bleeds to the card edges */\n  .vs-stat-cards-card { --vs-stat-cards-spark-c: var(--vs-stat-cards-good); }\n  .vs-stat-cards-card[data-trend=\"bad\"] { --vs-stat-cards-spark-c: var(--vs-stat-cards-bad); }\n  .vs-stat-cards-card[data-trend=\"flat\"] { --vs-stat-cards-spark-c: var(--vs-stat-cards-mute); }\n  .vs-stat-cards-spark { position: relative; height: var(--vs-stat-cards-spark-h); margin: 14px calc(-1 * var(--vs-stat-cards-pad)) 0; }\n  .vs-stat-cards-spark svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }\n  .vs-stat-cards-spark .vs-stat-cards-ln { fill: none; stroke: var(--vs-stat-cards-spark-c); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }\n  .vs-stat-cards-spark .vs-stat-cards-ghost { fill: none; stroke: var(--vs-stat-cards-spark-c); stroke-opacity: .22; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }\n  .vs-stat-cards-spark .vs-stat-cards-stop { stop-color: var(--vs-stat-cards-spark-c); }\n  .vs-stat-cards-spark .vs-stat-cards-bar { fill: var(--vs-stat-cards-spark-c); }\n  .vs-stat-cards-spark .vs-stat-cards-hair { stroke: var(--vs-stat-cards-ink); stroke-opacity: .22; stroke-width: 1; stroke-dasharray: 2 3; shape-rendering: crispEdges; transition: opacity .25s; opacity: 0; }\n  .vs-stat-cards-card.is-scrub .vs-stat-cards-hair { opacity: 1; }\n  .vs-stat-cards-spark .vs-stat-cards-dot { fill: var(--vs-stat-cards-spark-c); stroke: var(--vs-stat-cards-bg); stroke-width: 2.5; }\n  .vs-stat-cards-spark .vs-stat-cards-halo { fill: var(--vs-stat-cards-spark-c); opacity: .16; }\n  .vs-stat-cards[data-vs-stat-cards-spark=\"bars\"] .vs-stat-cards-dot,\n  .vs-stat-cards[data-vs-stat-cards-spark=\"bars\"] .vs-stat-cards-halo,\n  .vs-stat-cards[data-vs-stat-cards-spark=\"bars\"] .vs-stat-cards-ghost { display: none; }\n  .vs-stat-cards-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n</style>\n\n<div class=\"vs-stat-cards\" data-vs-stat-cards-spark=\"area\">\n  <article class=\"vs-stat-cards-card\" data-good=\"up\" data-prefix=\"$\" data-decimals=\"0\" data-delta=\"pct\">\n    <h3 class=\"vs-stat-cards-label\">Net revenue</h3>\n    <p class=\"vs-stat-cards-value\">$482,190</p>\n    <p class=\"vs-stat-cards-sub\">Last 30 days</p>\n    <script type=\"application/json\">{\"start\":\"2026-08-31\",\"values\":[428995,440013,431873,429290,435531,436319,439279,449421,451857,448705,448715,448086,453336,451763,458336,464648,463826,454300,461702,462072,472726,476202,470161,474000,476982,473860,476134,480383,489097,482190]}</script>\n  </article>\n  <article class=\"vs-stat-cards-card\" data-good=\"up\" data-decimals=\"0\" data-delta=\"pct\">\n    <h3 class=\"vs-stat-cards-label\">Active seats</h3>\n    <p class=\"vs-stat-cards-value\">18,406</p>\n    <p class=\"vs-stat-cards-sub\">Last 30 days</p>\n    <script type=\"application/json\">{\"start\":\"2026-08-31\",\"values\":[17853,17889,17955,17871,17847,17899,17971,18002,18117,18059,18069,18091,18007,18090,18179,18195,18178,18131,18231,18141,18236,18281,18348,18377,18303,18253,18315,18347,18404,18406]}</script>\n  </article>\n  <article class=\"vs-stat-cards-card\" data-good=\"down\" data-suffix=\"%\" data-decimals=\"2\" data-delta=\"abs\" data-delta-suffix=\" pt\" data-delta-decimals=\"1\">\n    <h3 class=\"vs-stat-cards-label\">Churn</h3>\n    <p class=\"vs-stat-cards-value\">1.82%</p>\n    <p class=\"vs-stat-cards-sub\">Last 30 days</p>\n    <script type=\"application/json\">{\"start\":\"2026-08-31\",\"values\":[2.22,2.24,2.22,2.12,2.19,2.12,2.18,2.16,2.15,2.1,2.06,2.05,2.08,2.02,2.07,1.99,2.01,1.94,1.91,1.91,1.98,2,1.88,1.95,1.81,1.83,1.79,1.79,1.93,1.82]}</script>\n  </article>\n  <article class=\"vs-stat-cards-card\" data-good=\"down\" data-format=\"duration\" data-delta=\"abs\">\n    <h3 class=\"vs-stat-cards-label\">Avg. response</h3>\n    <p class=\"vs-stat-cards-value\">1m 42s</p>\n    <p class=\"vs-stat-cards-sub\">Last 30 days</p>\n    <script type=\"application/json\">{\"start\":\"2026-08-31\",\"values\":[94,94,95,94,89,98,100,103,103,102,98,92,91,96,96,96,101,95,99,97,102,100,107,99,98,102,95,102,99,102]}</script>\n  </article>\n</div>\n\n<script>\n(() => {\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const NS = 'http://www.w3.org/2000/svg';\n  let uid = 0;\n  const svgEl = (tag, attrs, parent) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (parent) parent.appendChild(e); return e; };\n  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n  const ARROW = '<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M8 12.5v-9M4.2 7.2 8 3.4l3.8 3.8\"/></svg>';\n\n  // semi-implicit Euler spring, fixed 120 Hz sub-steps; returns true while moving\n  const spring = (s, k, c, dt, eps) => {\n    for (let h = 0; h < dt; h += 1 / 120) { const t = Math.min(1 / 120, dt - h); s.v += (-k * (s.x - s.g) - c * s.v) * t; s.x += s.v * t; }\n    if (Math.abs(s.x - s.g) < eps && Math.abs(s.v) < eps * 8) { s.x = s.g; s.v = 0; return false; }\n    return true;\n  };\n  const S = (x) => ({ x, v: 0, g: x });\n\n  // Fritsch–Carlson monotone cubic: tangents, path and y(x) on the same curve\n  const tangents = (ys) => {\n    const n = ys.length, d = [], m = new Array(n).fill(0);\n    for (let i = 0; i < n - 1; i++) d.push(ys[i + 1] - ys[i]);\n    m[0] = d[0]; m[n - 1] = d[n - 2];\n    for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;\n    for (let i = 0; i < n - 1; i++) {\n      if (d[i] === 0) { m[i] = m[i + 1] = 0; continue; }\n      const a = m[i] / d[i], b = m[i + 1] / d[i], h = a * a + b * b;\n      if (h > 9) { const t = 3 / Math.sqrt(h); m[i] = t * a * d[i]; m[i + 1] = t * b * d[i]; }\n    }\n    return m;\n  };\n\n  function initCard(card, root) {\n    let data; try { data = JSON.parse(card.querySelector('script[type=\"application/json\"]').textContent); } catch (e) { return null; }\n    const vals = (data.values || []).map(Number); if (vals.length < 2) return null;\n    const ds = card.dataset, locale = ds.locale || 'en-GB';\n    const dec = parseInt(ds.decimals || '0', 10), isDur = ds.format === 'duration';\n    const nf = new Intl.NumberFormat(locale, { minimumFractionDigits: dec, maximumFractionDigits: dec });\n    const fmt = (v) => {\n      if (isDur) { const t = Math.max(0, Math.round(v)), m = Math.floor(t / 60), s = t % 60; return m ? `${m}m ${String(s).padStart(2, '0')}s` : `${s}s`; }\n      return (ds.prefix || '') + nf.format(v) + (ds.suffix || '');\n    };\n    const dd = parseInt(ds.deltaDecimals || '1', 10);\n    const delta = (v) => {\n      const d = ds.delta === 'abs' ? v - vals[0] : (v - vals[0]) / vals[0] * 100;\n      const r = +d.toFixed(isDur && ds.delta === 'abs' ? 0 : dd);\n      const mag = Math.abs(r);\n      const txt = ds.delta === 'abs' ? (isDur ? fmt(mag) : mag.toFixed(dd) + (ds.deltaSuffix || '')) : mag.toFixed(dd) + '%';\n      return { sign: Math.sign(r), text: (r > 0 ? '+' : r < 0 ? '−' : '') + txt };\n    };\n    const start = new Date((data.start || \"2026-01-01\") + \"T12:00:00\"), stepDays = data.step || 1;\n    const df = new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' });\n    // \"Mon 14 Sep\": parts trimmed to three letters, so en-GB's \"Sept\" doesn't break the rhythm\n    const dateOf = (i) => { const o = {}; df.formatToParts(new Date(start.getTime() + i * stepDays * 864e5)).forEach((q) => (o[q.type] = q.value)); return `${o.weekday.slice(0, 3)} ${o.day} ${o.month.slice(0, 3)}`; };\n    const n = vals.length, last = n - 1;\n\n    // DOM\n    const label = card.querySelector('.vs-stat-cards-label');\n    const valueEl = card.querySelector('.vs-stat-cards-value');\n    const sub = card.querySelector('.vs-stat-cards-sub');\n    const top = document.createElement('div'); top.className = 'vs-stat-cards-top';\n    card.insertBefore(top, label); top.appendChild(label);\n    const badge = document.createElement('span'); badge.className = 'vs-stat-cards-delta'; badge.setAttribute('aria-hidden', 'true');\n    badge.innerHTML = ARROW + '<span></span>'; top.appendChild(badge);\n    const arrow = badge.querySelector('svg'), badgeTxt = badge.lastChild;\n    valueEl.setAttribute('aria-hidden', 'true');\n    const period = sub.textContent.trim() || 'Latest';\n    sub.textContent = ''; sub.setAttribute('aria-hidden', 'true');\n    const per = document.createElement('span'); per.className = 'vs-stat-cards-period'; per.textContent = period;\n    const dateEl = document.createElement('span'); dateEl.className = 'vs-stat-cards-date';\n    sub.append(per, dateEl);\n    const sr = document.createElement('span'); sr.className = 'vs-stat-cards-sr';\n    const live = document.createElement('span'); live.className = 'vs-stat-cards-sr'; live.setAttribute('aria-live', 'polite');\n    const d0 = delta(vals[last]);\n    sr.textContent = `${label.textContent}: ${fmt(vals[last])}, ${d0.text} ${period.toLowerCase()}. ${dateOf(0)} ${fmt(vals[0])} to ${dateOf(last)} ${fmt(vals[last])}. Use left and right arrow keys to explore.`;\n    card.append(sr, live);\n    if (!card.hasAttribute('tabindex')) card.tabIndex = 0;\n    card.setAttribute('role', 'group');\n    card.setAttribute('aria-label', label.textContent);\n\n    const wrap = document.createElement('div'); wrap.className = 'vs-stat-cards-spark'; wrap.setAttribute('aria-hidden', 'true');\n    card.appendChild(wrap);\n    const svg = svgEl('svg', { preserveAspectRatio: 'none' }, wrap);\n    const id = 'vs-stat-cards-' + (++uid);\n    const defs = svgEl('defs', {}, svg);\n    const grad = svgEl('linearGradient', { id: id + 'g', x1: 0, y1: 0, x2: 0, y2: 1 }, defs);\n    svgEl('stop', { offset: 0, 'stop-opacity': .26, class: 'vs-stat-cards-stop' }, grad);\n    svgEl('stop', { offset: .7, 'stop-opacity': .05, class: 'vs-stat-cards-stop' }, grad);\n    svgEl('stop', { offset: 1, 'stop-opacity': 0, class: 'vs-stat-cards-stop' }, grad);\n    const clip = svgEl('clipPath', { id: id + 'c' }, defs);\n    const clipR = svgEl('rect', { x: -4, y: -20, width: 0, height: 400 }, clip);\n    const clipA = svgEl('clipPath', { id: id + 'a' }, defs);\n    const clipAR = svgEl('rect', { x: -4, y: -20, width: 0, height: 400 }, clipA);\n    const ghost = svgEl('path', { class: 'vs-stat-cards-ghost' }, svg);\n    const area = svgEl('path', { fill: `url(#${id}g)`, 'clip-path': `url(#${id}a)` }, svg);\n    const line = svgEl('path', { class: 'vs-stat-cards-ln', 'clip-path': `url(#${id}c)` }, svg);\n    const barsG = svgEl('g', {}, svg);\n    const hair = svgEl('line', { class: 'vs-stat-cards-hair' }, svg);\n    const halo = svgEl('circle', { class: 'vs-stat-cards-halo', r: 9 }, svg);\n    const dot = svgEl('circle', { class: 'vs-stat-cards-dot', r: 4.5 }, svg);\n    let bars = [];\n\n    const st = {\n      num: S(vals[last]), dot: S(last), draw: S(1), pop: S(1), rot: S(d0.sign < 0 ? 180 : 0),\n      idx: -1, W: 0, H: 0, pad: 22, xs: [], ys: [], m: [], mode: '', entered: false, delayUntil: 0,\n    };\n    const geo = () => {\n      const r = wrap.getBoundingClientRect(); st.W = r.width; st.H = r.height;\n      st.pad = parseFloat(getComputedStyle(card).paddingLeft) || 22;\n      st.mode = root.getAttribute('data-vs-stat-cards-spark') || 'area';\n      svg.setAttribute('viewBox', `0 0 ${st.W} ${st.H}`);\n      let lo = Math.min(...vals), hi = Math.max(...vals); if (hi - lo < 1e-9) { hi += 1; lo -= 1; }\n      const top = Math.max(7, st.H * .13), bot = st.mode === 'bars' ? 0 : st.H * .24;\n      st.xs = vals.map((_, i) => st.pad + (st.W - 2 * st.pad) * i / last);\n      st.ys = st.mode === 'bars'\n        ? vals.map((v) => st.H - bot - (st.H - top - bot) * (0.16 + 0.84 * (v - lo) / (hi - lo)))\n        : vals.map((v) => top + (st.H - top - bot) * (1 - (v - lo) / (hi - lo)));\n      st.m = tangents(st.ys);\n      const bars_ = st.mode === 'bars';\n      const h = (st.W - 2 * st.pad) / last;\n      let d = `M${st.xs[0].toFixed(2)},${st.ys[0].toFixed(2)}`;\n      for (let i = 0; i < last; i++) d += `C${(st.xs[i] + h / 3).toFixed(2)},${(st.ys[i] + st.m[i] / 3).toFixed(2)} ${(st.xs[i + 1] - h / 3).toFixed(2)},${(st.ys[i + 1] - st.m[i + 1] / 3).toFixed(2)} ${st.xs[i + 1].toFixed(2)},${st.ys[i + 1].toFixed(2)}`;\n      line.setAttribute('d', d); ghost.setAttribute('d', d);\n      st.len = bars_ ? 0 : ghost.getTotalLength();\n      area.setAttribute('d', `${d}L${st.xs[last].toFixed(2)},${st.H}L${st.xs[0].toFixed(2)},${st.H}Z`);\n      hair.setAttribute('y1', 4); hair.setAttribute('y2', st.H);\n      line.style.display = area.style.display = bars_ ? 'none' : '';\n      area.style.visibility = st.mode === 'line' ? 'hidden' : '';\n      barsG.textContent = ''; bars = [];\n      if (bars_) {\n        const bw = Math.max(2, h * .58);\n        vals.forEach((_, i) => bars.push(svgEl('rect', { class: 'vs-stat-cards-bar', x: (st.xs[i] - bw / 2).toFixed(2), width: bw.toFixed(2), rx: Math.min(2, bw / 3) }, barsG)));\n      }\n      paint();\n    };\n    // y on the rendered curve at fractional index f (Hermite, same tangents as the path)\n    const yAt = (f) => {\n      f = clamp(f, 0, last); const i = Math.min(last - 1, Math.floor(f)), t = f - i, h = 1;\n      const t2 = t * t, t3 = t2 * t;\n      return (2 * t3 - 3 * t2 + 1) * st.ys[i] + (t3 - 2 * t2 + t) * h * st.m[i] + (-2 * t3 + 3 * t2) * st.ys[i + 1] + (t3 - t2) * h * st.m[i + 1];\n    };\n\n    let shownText = '', shownDelta = '', shownTone = '';\n    const setDelta = (v) => {\n      const d = delta(v);\n      if (d.text !== shownDelta) { badgeTxt.textContent = d.text; shownDelta = d.text; }\n      const tone = d.sign === 0 ? 'flat' : ((d.sign > 0) === ((ds.good || 'up') === 'up') ? 'good' : 'bad');\n      if (tone !== shownTone) { badge.dataset.tone = tone; shownTone = tone; }\n      st.rot.g = d.sign < 0 ? 180 : d.sign === 0 ? 90 : 0;\n    };\n\n    function paint() {\n      if (!st.W) return;\n      const txt = fmt(st.num.x);\n      if (txt !== shownText) { valueEl.textContent = txt; shownText = txt; }\n      const p = clamp(st.draw.x, 0, 1);\n      const f = clamp(st.dot.x, 0, last);\n      const x = st.pad + (st.W - 2 * st.pad) * f / last;\n      clipR.setAttribute('width', (x + 4).toFixed(2));\n      if (st.mode === 'bars') {\n        const ai = Math.round(f);\n        bars.forEach((b, i) => {\n          const q = clamp((p - i / n * .55) / .45, 0, 1), e = 1 - Math.pow(1 - q, 3);\n          const y = st.ys[i], full = st.H - y, hh = full * e;\n          b.setAttribute('y', (st.H - hh).toFixed(2)); b.setAttribute('height', Math.max(0, hh).toFixed(2));\n          b.setAttribute('fill-opacity', i === ai ? 1 : i < ai ? .42 : .16);\n        });\n      } else {\n        if (p < 1) { line.style.strokeDasharray = `${st.len.toFixed(2)} ${(st.len + 10).toFixed(2)}`; line.style.strokeDashoffset = ((1 - Math.max(0, p)) * st.len).toFixed(2); } else line.style.strokeDasharray = line.style.strokeDashoffset = '';\n        const a = clamp((p - .35) / .65, 0, 1);\n        area.style.opacity = a.toFixed(3);\n        area.style.transform = `translateY(${((1 - a) * 8).toFixed(2)}px)`;\n        ghost.style.opacity = p >= 1 ? 1 : 0;\n      }\n      let y = yAt(f), hx = x;\n      // while the line draws, the dot leads it and the area is unmasked up to the head\n      if (st.mode !== 'bars' && p < 1 && st.len) { const q = ghost.getPointAtLength(Math.max(0, p) * st.len); hx = q.x; y = q.y; }\n      clipAR.setAttribute('width', (Math.min(x, hx) + 4).toFixed(2));\n      hair.setAttribute('x1', x.toFixed(1)); hair.setAttribute('x2', x.toFixed(1));\n      const dp = st.mode === 'bars' ? 0 : clamp(p / .04, 0, 1), hp = clamp((p - .9) / .1, 0, 1);\n      dot.setAttribute('cx', hx.toFixed(2)); dot.setAttribute('cy', y.toFixed(2)); dot.setAttribute('r', ((3 + 1.5 * hp) * dp).toFixed(2));\n      halo.setAttribute('cx', hx.toFixed(2)); halo.setAttribute('cy', y.toFixed(2)); halo.setAttribute('r', (9 * hp * (st.idx >= 0 ? 1.25 : 1)).toFixed(2));\n      badge.style.transform = `scale(${Math.max(0, st.pop.x).toFixed(4)})`;\n      badge.style.opacity = clamp(st.pop.x * 1.6, 0, 1).toFixed(3);\n      arrow.style.transform = `rotate(${st.rot.x.toFixed(2)}deg)`;\n    }\n\n    function step(dt, now) {\n      if (now < st.delayUntil) return true;\n      const range = Math.abs(vals[last] - vals[0]) + Math.abs(vals[last]) * 1e-3;\n      let busy = false;\n      const scrub = st.idx >= 0;\n      busy = spring(st.num, scrub ? 240 : 60, scrub ? 28 : 15.5, dt, range * 1e-5 + 1e-6) || busy;\n      busy = spring(st.dot, 300, 30, dt, 1e-3) || busy;\n      busy = spring(st.draw, 38, 12.3, dt, 1e-3) || busy;\n      if (st.draw.x > .55 && st.pop.g !== 1) { st.pop.g = 1; }\n      busy = spring(st.pop, 420, 17, dt, 1e-3) || busy;\n      busy = spring(st.rot, 360, 22, dt, .05) || busy;\n      paint();\n      return busy;\n    }\n\n    const scrubTo = (i, announce) => {\n      i = clamp(Math.round(i), 0, last);\n      if (i === st.idx) return;\n      st.idx = i; card.classList.add('is-scrub');\n      dateEl.textContent = dateOf(i);\n      st.num.g = vals[i]; st.dot.g = i; setDelta(vals[i]);\n      if (reduced) { st.num.x = st.num.g; st.dot.x = i; st.rot.x = st.rot.g; paint(); }\n      if (announce) live.textContent = `${dateOf(i)}: ${fmt(vals[i])}, ${delta(vals[i]).text} since ${dateOf(0)}`;\n      card.dispatchEvent(new CustomEvent('vs-stat-cards:scrub', { detail: { index: i, value: vals[i], date: dateOf(i) } }));\n      kick();\n    };\n    const reset = () => {\n      if (st.idx < 0) return;\n      st.idx = -1; card.classList.remove('is-scrub');\n      st.num.g = vals[last]; st.dot.g = last; setDelta(vals[last]);\n      if (reduced) { st.num.x = st.num.g; st.dot.x = last; st.rot.x = st.rot.g; paint(); }\n      card.dispatchEvent(new CustomEvent('vs-stat-cards:scrub', { detail: { index: -1, value: vals[last], date: dateOf(last) } }));\n      kick();\n    };\n    const fromX = (cx) => { const r = wrap.getBoundingClientRect(); return (cx - r.left - st.pad) / (r.width - 2 * st.pad) * last; };\n\n    card.addEventListener('pointermove', (e) => { if (!st.entered) return; if (e.pointerType === 'mouse' || card.hasPointerCapture?.(e.pointerId) || e.buttons) scrubTo(fromX(e.clientX)); });\n    card.addEventListener('pointerdown', (e) => { if (!st.entered) return; scrubTo(fromX(e.clientX)); });\n    card.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') reset(); });\n    card.addEventListener('pointerup', (e) => { if (e.pointerType !== 'mouse') reset(); });\n    card.addEventListener('pointercancel', reset);\n    card.addEventListener('keydown', (e) => {\n      const cur = st.idx < 0 ? last : st.idx;\n      const k = e.key;\n      if (k === 'ArrowLeft' || k === 'ArrowDown') scrubTo(cur - (e.shiftKey ? 7 : 1), true);\n      else if (k === 'ArrowRight' || k === 'ArrowUp') scrubTo(cur + (e.shiftKey ? 7 : 1), true);\n      else if (k === 'Home') scrubTo(0, true);\n      else if (k === 'End') scrubTo(last, true);\n      else if (k === 'Escape') { reset(); live.textContent = ''; }\n      else return;\n      e.preventDefault();\n    });\n    card.addEventListener('blur', reset);\n\n    const enter = (delay) => {\n      st.entered = true;\n      if (reduced) { st.draw = S(1); st.num = S(vals[last]); st.pop = S(1); st.dot = S(last); paint(); kick(); return; }\n      st.num = S(0); st.num.g = vals[last];\n      st.draw = S(0); st.draw.g = 1;\n      st.pop = S(0); st.pop.g = 0;\n      st.dot = S(last);\n      st.rot.x = st.rot.g - 120; // the arrow swings into place as the badge pops\n      st.delayUntil = performance.now() + delay;\n      paint(); kick();\n    };\n    const hide = () => { st.entered = false; st.num = S(0); st.draw = S(0); st.pop = S(0); st.idx = -1; card.classList.remove('is-scrub'); paint(); };\n\n    let kick = () => {};\n    setDelta(vals[last]); st.rot.x = st.rot.g;\n    card.dataset.trend = shownTone;\n    return { card, st, step, geo, enter, hide, paint, setKick: (f) => (kick = f) };\n  }\n\n  function mount(root) {\n    if (root.vsStatCards) return;\n    const cards = [...root.querySelectorAll('.vs-stat-cards-card')].map((c) => initCard(c, root)).filter(Boolean);\n    let raf = 0, last = 0, visible = false;\n    const loop = (now) => {\n      const dt = Math.min(.05, (now - (last || now)) / 1000); last = now;\n      let busy = false;\n      cards.forEach((c) => { if (c.step(dt, now)) busy = true; });\n      raf = busy && visible ? requestAnimationFrame(loop) : 0;\n      if (!raf) last = 0;\n    };\n    const kick = () => { if (!raf && visible) raf = requestAnimationFrame(loop); };\n    cards.forEach((c) => c.setKick(kick));\n    cards.forEach((c) => c.hide());\n    // each card enters when it scrolls in; cards arriving together stagger\n    const seen = new Set(), vis = new Set();\n    let batch = 0, batchT = 0;\n    const enterCard = (c) => { const now = performance.now(); if (now - batchT > 250) batch = 0; batchT = now; c.enter(batch++ * 110); };\n    const ro = new ResizeObserver(() => cards.forEach((c) => c.geo()));\n    cards.forEach((c) => ro.observe(c.card));\n    const io = new IntersectionObserver((es) => {\n      es.forEach((e) => {\n        const c = cards.find((k) => k.card === e.target);\n        if (e.isIntersecting) { vis.add(c); if (!seen.has(c) && e.intersectionRatio >= .15) { seen.add(c); enterCard(c); } } else vis.delete(c);\n      });\n      visible = vis.size > 0;\n      if (visible) kick();\n    }, { threshold: [0, .15, .5] });\n    cards.forEach((c) => io.observe(c.card));\n    new MutationObserver(() => cards.forEach((c) => c.geo())).observe(root, { attributes: true, attributeFilter: ['data-vs-stat-cards-spark'] });\n    root.vsStatCards = { replay: () => { cards.forEach((c) => c.hide()); seen.clear(); batch = 0; batchT = performance.now(); cards.forEach((c) => { if (vis.has(c)) { seen.add(c); enterCard(c); } }); } };\n  }\n  const init = () => document.querySelectorAll('.vs-stat-cards').forEach(mount);\n  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Data display",
    "tags": [
      "kpi",
      "stats",
      "dashboard",
      "sparkline",
      "metrics",
      "cards",
      "counter",
      "analytics",
      "spring"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://api.fontshare.com/v2/css?f[]=general-sans@300,400,500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#177245",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/stat-cards/index.html",
    "thumbnail": "https://vividsites.app/api/component/stat-cards/thumb.jpg",
    "video": "https://vividsites.app/api/component/stat-cards/thumb.mp4",
    "page": "https://vividsites.app/c/stat-cards"
  }
}