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
- Paste the snippet just before
</head>. - Add
data-stat-cardsto a container; each child is a card. - In each card, put the number in an element with
data-statand the change in one withdata-trend, both written exactly as they should end up, e.g.$48,250and+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-invert | On a trend: down is good, so it’s green going down and red going up. For costs, churn or load times. |
data-from | On a number: its starting value. Default 0. |
data-duration | Time to count. Default 1800 ms. |
data-stagger | Delay between cards. Default 140 ms. |
data-loop | Count again every few seconds. |
data-hold | Pause 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.
<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><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>