You Might Also Like
Animated Stat Row — Free HTML CSS JS Dashboard Snippet
Animated Stat Row · Dashboards · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Animated Stat Row — Count-Up KPI Cards That Reveal on Scroll with Stagger

Numbers land harder when they animate. A row of KPI cards that fade up and count from zero to their value the moment they scroll into view is a staple of dashboards, landing pages, and annual reports — it draws the eye to the metrics and makes static figures feel alive. This component is a complete, responsive stat row: four cards with coloured icons, a count-up value, a label, and a period-over-period delta badge, that reveal with a stagger and count up using an eased animation, triggered by IntersectionObserver and respectful of reduced-motion preferences. It is built in HTML, CSS, and vanilla JavaScript.
Scroll-triggered, not on-load
The animation fires when the cards enter the viewport, not when the page loads — so if the stat row is below the fold, the count-up plays right as the user scrolls to it for maximum impact. IntersectionObserver watches each card with a threshold: 0.4 (40% visible), and on intersection it reveals and counts that card, then unobserves it so the animation runs exactly once. This is far more efficient than a scroll-position listener and guarantees the numbers are not already finished animating before the user ever sees them.
The eased count-up
countUp() drives the number with requestAnimationFrame, not setInterval — so it is synced to the display's refresh rate and never drops frames or drifts. Each frame computes progress t from performance.now() over a 1400ms duration, then applies an easeOutExpo curve (1 - 2^(-10t)): the number races up at the start and decelerates smoothly into its final value, which feels much more dynamic than a linear count. The displayed value is rounded and run through toLocaleString() so large numbers keep thousands separators, with optional prefix ($) and suffix (%) read from data attributes.
Data attributes drive everything
Each card declares its target in markup: data-to for the final number, data-prefix and data-suffix for formatting. The script reads these, so there is no hard-coded list in the JavaScript — to change a stat you edit the HTML. The value element uses font-variant-numeric: tabular-nums so every digit is the same width, which stops the card from jittering horizontally as the number rapidly changes during the count.
The staggered reveal
Cards do not all animate at once — each is delayed by its index times 120ms, so they cascade left to right. The reveal itself is CSS: cards start at opacity: 0 and translateY(14px) and transition to visible when the .in class is added, with a slight overshoot easing. The stagger plus the count-up together create the "dashboard coming to life" effect without any animation library.
Respecting reduced motion
Users who set prefers-reduced-motion: reduce (for vestibular comfort) should not be subjected to counting numbers and sliding cards. The script checks matchMedia('(prefers-reduced-motion: reduce)') and, when true, sets each value straight to its final figure with no count-up. The reveal transition is gentle enough to keep, but the potentially dizzying count animation is skipped — the accessible, considerate default.
The delta badges and customisation
Each card shows a coloured delta pill — green for up, red for down, grey for flat — to give the number context at a glance. Icons are tinted from a single --c custom property per card using color-mix for the soft background, so theming a card is one value. The row is a four-column grid that collapses to two columns under 760px and one under 380px. To use it, edit the cards' targets, labels, icons, colours, and deltas; the animation logic adapts automatically.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to derive the easing curve or the observer logic by hand — paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how the easeOutExpo formula (1 minus 2 to the power of negative 10t) produces a fast-start, decelerating count, and why unobserve is called on each card right after it fires so the animation never replays on repeated scrolling. The same assistant is useful for optimizing it — asking whether four separate requestAnimationFrame loops (one per card) should be consolidated into a single loop that updates all in-progress cards, especially if this row were repeated many times on one long dashboard page. It's just as good for extending it: ask it to support decimal-value stats (not just integers), add a hover state that reveals the exact previous-period number behind the delta badge, or drive the data-to targets from a live API response instead of hardcoded data attributes. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build an "animated stat row" of KPI cards in plain HTML, CSS, and JavaScript — no libraries, using IntersectionObserver and requestAnimationFrame, with each card's target value read from data attributes rather than hardcoded in the script.
Requirements:
- A responsive grid of stat cards, each declaring its final numeric value plus an optional prefix and suffix string via data attributes (e.g. data-to, data-prefix, data-suffix) directly in the HTML, with an icon, a large value display, a label, and a colored up/down/flat delta badge.
- Each card starts invisible and slightly offset (opacity 0, translated down a few pixels). Use an IntersectionObserver watching all cards with a threshold around 0.4, so a card only begins animating once it is meaningfully visible in the viewport, and call unobserve on each card immediately after it fires so the animation never re-triggers on repeated scrolling.
- When a card becomes visible, add a class that triggers its CSS reveal transition, and simultaneously start a JavaScript count-up of its number from zero to its target using requestAnimationFrame (not setInterval), computing progress from performance.now() over a fixed duration and applying an easeOutExpo easing curve (fast start, decelerating finish) rather than a linear ramp.
- Format the displayed number on every frame by rounding it and passing it through toLocaleString so thousands separators appear correctly, wrapping it with the card's prefix and suffix strings. Use a numeric font feature (tabular figures) on the value element so the digits don't jitter the layout width as they change.
- Stagger multiple simultaneously-visible cards so they don't all animate at the exact same instant — delay each one by its index times a fixed small amount (like 120ms) so they cascade in sequence.
- Check for prefers-reduced-motion via matchMedia at the top of the script, and when it's set, skip the count-up animation entirely, writing each card's final formatted value immediately instead (though the fade-in reveal transition may remain, since it's not the actual source of motion discomfort).Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Paste the HTML, CSS, and JSA row of four KPI cards renders; when it scrolls into view the cards fade up in sequence and their numbers count from zero.
- 2Scroll it into viewIntersectionObserver triggers each card at 40% visibility, staggered 120ms apart, with an easeOutExpo count-up over 1.4s.
- 3Check the deltasEach card shows a coloured up/down/flat pill giving period-over-period context beside the value.
- 4Edit the statsChange each card's data-to target, data-prefix/data-suffix, label, icon, and --c colour directly in the markup.
- 5Resize the windowThe four-column grid collapses to two columns under 760px and one under 380px.
- 6Respect reduced motionUsers with prefers-reduced-motion get the final numbers immediately with no count-up — no code change needed.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
If the stat row is below the fold and you animate on load, the count-up finishes before the user scrolls to it, so they miss the effect entirely. IntersectionObserver starts the animation the moment the cards become visible (40% here), so the count-up plays right when the user is looking. It also avoids a scroll-event listener, and unobserve() after firing ensures each card animates exactly once.
requestAnimationFrame syncs to the display's refresh rate, so the count is smooth and pauses automatically when the tab is backgrounded. setInterval runs on a fixed timer that can drift, fire during repaints, or stack up, causing janky or inconsistent counting. Computing progress from performance.now() each frame also makes the total duration exact regardless of frame rate.
Each card carries data-to (the target number), data-prefix (e.g. $), and data-suffix (e.g. %). Edit those in the markup to change a stat — no JavaScript edit needed. To add a card, copy a .asr-stat block, set its data attributes, icon, --c colour, label, and delta; the observer picks it up automatically since the script queries all .asr-stat elements at load.
Yes. It checks window.matchMedia('(prefers-reduced-motion: reduce)') and, when set, writes each value straight to its final figure with no count-up animation. This respects users who experience discomfort from motion. The subtle fade-up reveal is kept since it is gentle, but the rapidly changing numbers — the part most likely to cause issues — are skipped.
Render the stats from an array of { to, prefix, suffix, label, color, delta }. Set up the IntersectionObserver in an effect (useEffect / onMounted / ngAfterViewInit) observing refs to the cards, and disconnect it on cleanup. Drive the displayed number with a rAF loop in the same effect (cancel it on unmount), storing the current value in state. Check prefers-reduced-motion before animating. The CSS — reveal, delta pills, color-mix theming — ports unchanged.