Skip to content

Web Performance

Animated Stat Counters — IntersectionObserver + requestAnimationFrame

Numbers That Move When You Look At Them

📊✨🎯
Static stat blocks are invisible. A user scrolls past "120+ Projects Delivered" and doesn't break stride. An animated counter that ticks from 0 to 120 over 1.6 seconds grabs their attention — eye-tracking studies show users fixate 40% longer on animated numerals than static text. The catch is that most stat animations are either clunky (setInterval with 60fps jank), bloated (third-party libraries), or overly simple (linear easing that feels robotic). The pattern here uses zero dependencies: IntersectionObserver to trigger when the stats enter the viewport, requestAnimationFrame for smooth 60fps animation, and cubic ease-out easing to make the counter decelerate naturally. It's small enough to inline in one script tag. Why IntersectionObserver beats scroll events Scroll events fire constantly — dozens of times per second. If you animate counters on every scroll event, you're re-triggering animations, fighting frame drops, and wasting battery on mobile. IntersectionObserver watches a specific element and fires exactly once when it enters the viewport. Set threshold to 0.3 (30% visible) so the counter starts before the element is fully in view. Once fired, disconnect the observer. Animation runs once, period. requestAnimationFrame beats setInterval setInterval(updateCounter, 16) tries to run every 16ms, but the browser can't guarantee it. If the main thread is busy, the callback queues up and fires late, creating visible stuttering. requestAnimationFrame syncs with the display's refresh rate — on a 60Hz monitor, it fires exactly 60 times per second; on 120Hz, it scales automatically. No dropped frames, no jank. The easing function is the secret Linear animation (counting 1, 2, 3, 4…) feels robotic. Cubic ease-out (fast start, slow finish) feels natural because it mimics deceleration — like throwing a ball and watching it slow down. The formula is eased = 1 - Math.pow(1 - progress, 3). Progress goes from 0 to 1 as the animation runs. Plug that eased value into your target number and you get smooth, satisfying motion. The implementation Mark your stat values with data-target attributes. Wrap the counter container in an id so IntersectionObserver can find it. When the observer fires, loop through each counter, start requestAnimationFrame, and calculate the eased value 60 times per second. Duration is 1600ms—long enough to read the number, short enough to feel snappy. The code is small (about 25 lines), self-contained, and doesn't require a build step or external package. Drop it in and animate. 6 FAQs Q: Should I use this for KPIs in dashboards? Yes, but only if the viewport is wide enough that the user isn't scrolling constantly. If they're hammering scroll, disconnect the observer after the first animation fires so you don't re-trigger it on the next viewport pass. Q: Can I change the duration? Absolutely. The duration variable controls animation length in milliseconds. 1600 is our default. Go shorter (800–1000) for snappier feel, longer (2000–2500) for drama. Q: What if the counter target is a decimal (e.g., 3.7M)? Math.floor strips decimals. If you need precision, use Math.round or toFixed(1). Just be aware that fast-moving decimals look janky — round to 1 decimal place at most. Q: Will this work on mobile? Perfectly. IntersectionObserver and requestAnimationFrame have 99%+ browser support. Mobile browsers actually handle it better than desktops because there's less competing thread work. Q: Can I add sound to the counter? You can, but don't. The visual motion is the dopamine hit. Audio adds no value and annoys users in offices. Skip it. Q: How many counters can I run simultaneously? Run 20+ counters at once and you'll still get solid 60fps because requestAnimationFrame batches all the updates into a single frame. The browser is efficient at this. The bottom line Animated stats are a zero-friction win. They cost nothing to add, they load instantly, and they tangibly increase dwell time on conversion-heavy pages. Use IntersectionObserver to trigger once, requestAnimationFrame for smooth motion, cubic ease-out for natural feel. No library needed. Ship it.
Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.