← Charts

Stat Cards

Dashboard cards rise in one after another while their numbers count up to the latest figures, keeping currency signs, commas and units, and a trend chip pops in beside each one, its arrow sliding up or down. Green when the change is good, red when it isn’t.

Revenue$48,250+12.4%
Active users12,480+8.1%
Churn rate2.1%−0.6%
Avg. load time1.84 s+0.12 s

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-stat-cards to a container; each child is a card.
  3. In each card, put the number in an element with data-stat and the change in one with data-trend, both written exactly as they should end up, e.g. $48,250 and +12.4%.

Options

Add any of these attributes to the same element.

data-trend="up" / "down" / "flat"Set the arrow yourself. By default the change’s sign decides.
data-invertOn a trend: down is good, so it’s green going down and red going up. For costs, churn or load times.
data-fromOn a number: its starting value. Default 0.
data-durationTime to count. Default 1800 ms.
data-staggerDelay between cards. Default 140 ms.
data-loopCount again every few seconds.
data-holdPause before recounting, when looping. Default 2600 ms.

The cards get a light default look with zero specificity, so your own styles win. Screen readers always get the final values. With reduced motion the cards simply show.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Stat Cards: a row of dashboard cards rises in one after another, each
    // number counting up to its value (keeping your formatting: currency signs,
    // commas, decimals and units) while a trend chip pops in beside it, its
    // arrow sliding up or down and a ring pulsing out once it lands.
    // Usage:
    //   <div data-stat-cards>
    //     <div><span>Revenue</span> <b data-stat>$48,250</b> <small data-trend>+12.4%</small></div>
    //     <div><span>Churn</span> <b data-stat>2.1%</b> <small data-trend data-invert>-0.6%</small></div>
    //   </div>
    // Each child is a card. Write the numbers exactly as they should end up.
    // The trend's sign sets the arrow; force it with data-trend="up", "down" or
    // "flat". Up is green and down is red; data-invert on a trend swaps that,
    // for numbers where down is good (costs, churn, load times).
    // Optional: data-from on a number (starting value, default 0),
    // data-duration (ms to count, default 1800), data-stagger (ms between
    // cards, default 140), data-loop (count again every few seconds),
    // data-hold (ms before recounting, default 2600).
    // The cards get a light default look you can restyle freely; screen
    // readers always get the final values.
    var DEFAULTS = { duration: 1800, stagger: 140, hold: 2600 };
    var COLORS = { good: ['#15803d', '#dcfce7'], bad: ['#dc2626', '#fee2e2'], flat: ['#6b6880', '#eeecf3'] };
    var ARROWS = { up: 'M12 19V5M5.5 11.5 12 5l6.5 6.5', down: 'M12 5v14m-6.5-6.5L12 19l6.5-6.5', flat: 'M5 12h14m-6.5-6.5L19 12l-6.5 6.5' };

    // :where() keeps the default look at zero specificity, so your own card
    // styles win without a fight.
    var css =
      ':where([data-stat-cards]){display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,180px),1fr));gap:16px}' +
      ':where([data-stat-cards]) > *{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:18px 20px;border-radius:16px;background:#fff;color:#15131f;box-shadow:0 1px 2px rgba(20,16,40,.06),0 8px 24px rgba(20,16,40,.07);text-align:left}' +
      ':where([data-stat-cards]) > * > :first-child:not([data-stat]){font-size:13px;font-weight:600;opacity:.62}' +
      ':where([data-stat-cards] [data-stat]){font-size:32px;font-weight:800;letter-spacing:-.035em;line-height:1.1}' +
      '[data-stat-cards] [data-stat]{font-variant-numeric:tabular-nums}' +
      ':where([data-stat-cards] [data-trend]){font-size:13px;font-weight:700;color:var(--stc-color);background:var(--stc-bg)}' +
      '[data-stat-cards] [data-trend]{position:relative;display:inline-flex;align-items:center;gap:3px;padding:3px 9px 3px 6px;border-radius:999px;font-variant-numeric:tabular-nums;white-space:nowrap}' +
      '.stc-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}' +
      '.stc-n{display:inline-block}' +
      '.stc-arrow{display:inline-block;width:1.15em;height:1.15em;overflow:hidden}' +
      '.stc-arrow svg{display:block;width:100%;height:100%}' +
      '.stc-ring{position:absolute;inset:0;border-radius:inherit;border:2px solid currentColor;opacity:0;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // "$1,250.50" → prefix "$", value 1250.5, 2 decimals, commas, suffix "".
    // A +, - or − just before the digits is kept apart as the sign.
    function parse(text) {
      var m = text.match(/^(\D*?)([\d,]*\.?\d+)(.*)$/);
      if (!m) return null;
      var num = m[2], prefix = m[1], sign = '';
      var s = prefix.match(/[+\-\u2212]\s*$/);
      if (s) { sign = s[0]; prefix = prefix.slice(0, s.index); }
      var decimals = num.indexOf('.') >= 0 ? num.split('.')[1].length : 0;
      return { prefix: prefix, sign: sign, suffix: m[3], value: parseFloat(num.replace(/,/g, '')), decimals: decimals, commas: num.indexOf(',') >= 0 };
    }

    function format(n, f) {
      var s = n.toFixed(f.decimals);
      if (f.commas) {
        var parts = s.split('.');
        parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
        s = parts.join('.');
      }
      return f.prefix + f.sign + s + f.suffix;
    }

    // Swap the text for a screen-reader copy of the final value and a visible
    // copy that counts.
    function counter(el, f) {
      var final = el.textContent.trim();
      el.textContent = '';
      var sr = document.createElement('span');
      sr.className = 'stc-sr';
      sr.textContent = final;
      var n = document.createElement('span');
      n.className = 'stc-n';
      n.setAttribute('aria-hidden', 'true');
      n.textContent = final;
      el.appendChild(sr);
      el.appendChild(n);
      return n;
    }

    function build(card) {
      var c = { card: card, stat: null, trend: null };
      var stat = card.querySelector('[data-stat]');
      var f = stat && parse(stat.textContent.trim());
      if (f) c.stat = { f: f, from: +stat.dataset.from || 0, n: counter(stat, f), state: { v: 0 } };
      var trend = card.querySelector('[data-trend]');
      if (trend) {
        var tf = parse(trend.textContent.trim());
        var dir = trend.dataset.trend;
        if (dir !== 'up' && dir !== 'down' && dir !== 'flat') dir = !tf || tf.value === 0 ? 'flat' : /^[-\u2212]/.test(tf.sign) ? 'down' : 'up';
        var good = dir === 'flat' ? 'flat' : (dir === 'up') === (trend.dataset.invert === undefined || trend.dataset.invert === 'false') ? 'good' : 'bad';
        trend.style.setProperty('--stc-color', COLORS[good][0]);
        trend.style.setProperty('--stc-bg', COLORS[good][1]);
        var n = counter(trend, tf);
        var box = document.createElement('span');
        box.className = 'stc-arrow';
        box.setAttribute('aria-hidden', 'true');
        box.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="' + ARROWS[dir] + '"/></svg>';
        var ring = document.createElement('span');
        ring.className = 'stc-ring';
        ring.setAttribute('aria-hidden', 'true');
        trend.insertBefore(box, n);
        trend.appendChild(ring);
        c.trend = { el: trend, f: tf, n: tf ? n : null, dir: dir, arrow: box.firstChild, ring: ring, state: { v: 0 } };
      }
      return c;
    }

    // Hold each number's final width so the cards don't jiggle while counting.
    function hold(c) {
      [c.stat, c.trend].forEach(function (s) {
        if (s && s.n) s.n.style.minWidth = s.n.getBoundingClientRect().width + 'px';
      });
    }

    function count(s, from, duration, delay) {
      s.state.v = from;
      s.n.textContent = format(from, s.f);
      anime.animate(s.state, {
        v: s.f.value, duration: duration, delay: delay, ease: 'out(4)',
        onUpdate: function () { s.n.textContent = format(s.state.v, s.f); },
        onComplete: function () { s.n.textContent = format(s.f.value, s.f); }
      });
    }

    function play(group, cards, first) {
      var d = group.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      cards.forEach(function (c, i) {
        var at = i * stagger;
        if (first) anime.animate(c.card, { opacity: [0, 1], translateY: [22, 0], scale: [0.96, 1], duration: 750, delay: at, ease: 'out(3)' });
        if (c.stat) {
          if (!first) anime.animate(c.stat.n, { opacity: [0, 1], duration: 300, ease: 'out(2)' });
          count(c.stat, c.stat.from, duration, at + 120);
        }
        var t = c.trend;
        if (!t) return;
        // The chip pops in as the count nears its end; the arrow slides in
        // from the way it points, then a ring pulses out once.
        var pop = at + 120 + duration * 0.45;
        var off = t.dir === 'up' ? 14 : t.dir === 'down' ? -14 : 0;
        anime.animate(t.el, { opacity: [0, 1], scale: [0.6, 1], duration: 650, delay: pop, ease: 'outBack(2.2)' });
        anime.animate(t.arrow, { translateY: [off, 0], translateX: [off ? 0 : -14, 0], opacity: [0, 1], duration: 700, delay: pop + 120, ease: 'outBack(1.6)' });
        anime.animate(t.ring, { scale: [1, 1.35], opacity: [0.45, 0], duration: 900, delay: pop + 520, ease: 'out(2)' });
        if (t.n) count(t, 0, duration * 0.5, pop);
      });
      if (d.loop === undefined || d.loop === 'false') return;
      var end = (cards.length - 1) * stagger + 120 + duration;
      setTimeout(function () { reset(cards, function () { play(group, cards, false); }); }, end + (+d.hold || DEFAULTS.hold));
    }

    // Before a recount: trend chips shrink away and numbers drop back.
    function reset(cards, done) {
      cards.forEach(function (c) {
        if (c.trend) anime.animate(c.trend.el, { opacity: 0, scale: 0.8, duration: 300, ease: 'in(2)' });
        if (c.stat) anime.animate(c.stat.n, { opacity: 0, duration: 300, ease: 'in(2)' });
      });
      setTimeout(done, 320);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target, e.target._stc, true);
        });
      }, { threshold: 0.35 });
      document.querySelectorAll('[data-stat-cards]').forEach(function (group) {
        if (group._stc) return;
        var cards = Array.prototype.map.call(group.children, build);
        group._stc = cards;
        if (reduced) return;
        cards.forEach(function (c) {
          hold(c);
          anime.utils.set(c.card, { opacity: 0 });
          if (c.stat) c.stat.n.textContent = format(c.stat.from, c.stat.f);
          if (c.trend) anime.utils.set(c.trend.el, { opacity: 0 });
        });
        io.observe(group);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-stat-cards>
  <div>
    <span>Revenue</span>
    <b data-stat>$48,250</b>
    <small data-trend>+12.4%</small>
  </div>
  <div>
    <span>Churn rate</span>
    <b data-stat>2.1%</b>
    <small data-trend data-invert>-0.6%</small>
  </div>
</div>