EmpireUI
Get Pro
← Blog7 min read#stat-counter#animated-numbers#dashboard-components

Stat Counter Cards: Animated Number Components for Dashboards

Build animated stat counter cards in React with Tailwind v4. Real number animations, smooth easing, and dashboard-ready components you can drop in today.

A dashboard interface showing animated stat counter cards with numeric metrics and charts on a dark background

Why Stat Counter Cards Matter for SaaS Dashboards

Honestly, a static number on a dashboard card is a missed opportunity. Users notice motion. When a counter animates from 0 to 42,891 on page load, it signals that something is alive, that data is fresh, that the product has polish. It takes maybe 80 lines of React to get there.

Stat counter cards are one of those components that show up on literally every SaaS product you've used — revenue totals, active user counts, conversion rates, weekly signups. The pattern is always the same: a big number, a label underneath, maybe a trend indicator. What separates a forgettable dashboard from one that feels crafted is whether those numbers animate in or just appear cold.

Empire UI ships a StatCounterCard component out of the box, covering the most common dashboard patterns. But this article also shows you how to build one from scratch so you actually understand what's happening under the hood.

How Animated Number Counting Works in React

The core mechanism is simple. You pick a start value (usually 0), an end value (the real metric), and a duration in milliseconds. Then you use requestAnimationFrame to increment the displayed number over time, applying an easing function so it doesn't look robotic.

The tricky part is easing. A linear increment feels mechanical — the number just ticks up at a fixed rate. What you actually want is an ease-out curve: fast at the start, slowing down as it approaches the target. The visual effect makes the number feel like it's settling into place. That's the difference between a widget and a polished UI element.

You'll also want to tie the animation to visibility. Animating a stat counter that's off-screen wastes cycles and means users miss the whole effect anyway. IntersectionObserver is your friend here — trigger the animation only when the card scrolls into view. This is especially relevant if you're building something like a bento grid layout where cards enter the viewport at different times.

Building a useCountUp Hook

Let's write the animation logic as a reusable hook first. This keeps the StatCounterCard component clean and lets you use the counter anywhere — tooltips, banners, wherever.

Here's a solid useCountUp implementation that handles easing, duration, and intersection-based triggering:

import { useEffect, useRef, useState } from 'react';

function easeOutQuart(t: number): number {
  return 1 - Math.pow(1 - t, 4);
}

interface UseCountUpOptions {
  end: number;
  start?: number;
  duration?: number; // ms
  decimals?: number;
  triggerOnce?: boolean;
}

export function useCountUp({
  end,
  start = 0,
  duration = 1800,
  decimals = 0,
  triggerOnce = true,
}: UseCountUpOptions) {
  const [value, setValue] = useState(start);
  const [isRunning, setIsRunning] = useState(false);
  const rafRef = useRef<number | null>(null);
  const startTimeRef = useRef<number | null>(null);

  const run = () => {
    if (isRunning) return;
    setIsRunning(true);
    startTimeRef.current = null;

    const step = (timestamp: number) => {
      if (!startTimeRef.current) startTimeRef.current = timestamp;
      const elapsed = timestamp - startTimeRef.current;
      const progress = Math.min(elapsed / duration, 1);
      const easedProgress = easeOutQuart(progress);
      const current = start + (end - start) * easedProgress;

      setValue(parseFloat(current.toFixed(decimals)));

      if (progress < 1) {
        rafRef.current = requestAnimationFrame(step);
      } else {
        setValue(end);
        setIsRunning(false);
      }
    };

    rafRef.current = requestAnimationFrame(step);
  };

  useEffect(() => {
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  return { value, run, isRunning };
}

The easeOutQuart function gives you a smooth deceleration. You could swap it for easeOutExpo if you want a snappier feel — 1 - Math.pow(2, -10 * t) is the formula. Pick whatever matches your product's motion language.

The StatCounterCard Component with Tailwind v4

With the hook in place, the component itself is mostly layout and styling. Using Tailwind v4.0.2, you get the new @layer cascade improvements which make it easier to keep your card styles composable without fighting specificity wars.

Here's a production-ready StatCounterCard:

import { useEffect, useRef } from 'react';
import { useCountUp } from './useCountUp';

interface StatCounterCardProps {
  label: string;
  value: number;
  prefix?: string;  // e.g. '$'
  suffix?: string;  // e.g. '%' or 'k'
  decimals?: number;
  duration?: number;
  trend?: { value: number; label: string };
  className?: string;
}

export function StatCounterCard({
  label,
  value,
  prefix = '',
  suffix = '',
  decimals = 0,
  duration = 1800,
  trend,
  className = '',
}: StatCounterCardProps) {
  const cardRef = useRef<HTMLDivElement>(null);
  const { value: displayValue, run } = useCountUp({
    end: value,
    duration,
    decimals,
    triggerOnce: true,
  });

  useEffect(() => {
    const el = cardRef.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          run();
          observer.unobserve(el);
        }
      },
      { threshold: 0.3 }
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const trendPositive = trend && trend.value >= 0;

  return (
    <div
      ref={cardRef}
      className={`
        relative rounded-2xl border border-white/10 bg-white/5
        backdrop-blur-md p-6 flex flex-col gap-2
        transition-shadow duration-300 hover:shadow-lg
        hover:shadow-black/20 ${className}
      `}
      style={{ background: 'rgba(255,255,255,0.04)' }}
    >
      <p className="text-sm font-medium text-white/50 tracking-wide uppercase">
        {label}
      </p>
      <p className="text-4xl font-bold tabular-nums text-white">
        {prefix}
        {decimals > 0
          ? displayValue.toFixed(decimals)
          : displayValue.toLocaleString()}
        {suffix}
      </p>
      {trend && (
        <span
          className={`text-xs font-semibold ${
            trendPositive ? 'text-emerald-400' : 'text-rose-400'
          }`}
        >
          {trendPositive ? '▲' : '▼'} {Math.abs(trend.value)}%{' '}
          <span className="text-white/40 font-normal">{trend.label}</span>
        </span>
      )}
    </div>
  );
}

Notice the tabular-nums class — that's doing real work here. Without it, the number shifts horizontally as digits change width, which looks jittery. One class fixes it entirely. Small details like that are what separate a component you can ship from one you have to fix later.

Formatting Numbers: Locale, Currency, and Compact Notation

The Intl API is criminally underused in dashboard components. Instead of hand-rolling 10001K conversions, just use Intl.NumberFormat. It handles locale correctly, respects decimal separators, and gives you compact notation for free.

For a revenue metric you might do: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).format(displayValue). That turns 1420000 into $1.4M automatically. For a percentage with two decimal places, notation: 'standard' with maximumFractionDigits: 2 is the right call.

One thing to watch: when you're animating through intermediate values, toLocaleString() calls can be expensive if you're calling them 60 times a second. Profile it. In most cases it's fine, but if you're rendering a grid of 20 stat cards simultaneously, you'll want to throttle the format calls or only format the final displayed integer.

Composing Stat Cards into a Dashboard Grid

Stat counter cards rarely live alone. They're almost always in a row or grid — four metrics across the top of an analytics page is the classic layout. The 8px gap between cards (gap-2 in Tailwind) is usually too tight; gap-4 (16px) or gap-6 (24px) gives them enough breathing room.

For responsive grids, grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 is the standard pattern. But it's worth thinking about which metrics deserve more visual weight. A revenue total might get col-span-2 while secondary metrics stay at col-span-1. This is where combining your stat cards with something like a cards stack component can create interesting visual hierarchy in dashboards with layered data.

Staggered animation entrance looks polished too. You can pass a delay prop and apply it inside the useEffect — just wrap the observer.observe(el) call in a setTimeout(fn, delay). With four cards at 0ms, 100ms, 200ms, 300ms delays, you get a cascade effect that guides the user's eye across the metrics. Don't overdo it though — anything over 400ms total feels sluggish.

Accessibility and Reduced Motion Considerations

Here's the thing: not everyone wants or can handle animated numbers. The prefers-reduced-motion media query exists for a reason, and ignoring it in a component you're shipping to users is genuinely inconsiderate. The fix is two lines.

In your hook, check window.matchMedia('(prefers-reduced-motion: reduce)').matches before starting the animation. If it's true, skip the RAF loop entirely and just set setValue(end) immediately. Full stop. The user sees the final number, no animation, no jitter. In React you can also use the useReducedMotion() hook from Framer Motion if you're already using that library.

Screen reader behavior is worth thinking about too. A live-region with aria-live="polite" will announce number changes, which can be extremely annoying during a 1.8-second count animation — it'll try to read every intermediate value. Wrap the animating number in aria-hidden="true" and put the final static value in a visually-hidden span with the actual metric. That way screen readers get the right number without the chaos. Pair this with a theme toggle that respects user system preferences and you've got a genuinely accessible component.

Performance Tips for Animated Dashboard Components

If you're mounting 10+ stat counter cards at once, it's worth thinking about whether they all need to animate simultaneously. Staggered delays help visually, but they also spread the RAF load over time instead of spiking it.

The will-change: transform CSS property can help if you're animating with CSS transitions alongside the counter — it hints to the browser to keep the element on the GPU. But use it sparingly. Setting will-change on everything is worse than setting it on nothing, because it forces the browser to allocate GPU memory eagerly for every element.

For dashboards with real-time data that updates every few seconds, you'll want to re-trigger the animation on value changes. Add value to the useEffect dependency array and reset startTimeRef.current = null before calling run() again. This gives you smooth re-animation from the current displayed value to the new target — which looks much better than jumping or re-running from 0. The same principles apply whether you're using Empire UI's stat cards standalone or alongside something like animated tabs for switching between metric views.

FAQ

How do I prevent the counter from animating multiple times when the user scrolls past the card?

Pass triggerOnce: true to the hook and call observer.unobserve(el) inside the IntersectionObserver callback after run() fires. Once the animation starts, the observer disconnects so it never triggers again.

Can I animate from a non-zero start value — for example, showing a change from 1,200 to 1,450?

Yes. Pass start: 1200 and end: 1450 to useCountUp. The hook animates between those two values using the same easing curve. This is useful for live dashboards where metrics update in small increments rather than from zero.

What's the right duration for a stat counter animation?

Between 1200ms and 2000ms is the sweet spot for most dashboards. Shorter than 1s feels rushed and users miss the effect; longer than 2s feels like the page is loading slowly. 1800ms with an ease-out-quart curve is a solid default.

How do I handle very large numbers like 1,432,891 without the animation looking like a blur?

Use notation: 'compact' via Intl.NumberFormat so the displayed value shows 1.4M instead of counting through all seven digits. The animation will still feel smooth and meaningful without the number becoming unreadable during the count.

Does Empire UI's StatCounterCard work with server components in Next.js?

No — it uses useEffect, useRef, requestAnimationFrame, and IntersectionObserver, all of which are client-side APIs. Add 'use client' at the top of the file. You can fetch the data server-side and pass it as props to the client component.

How do I add a sparkline or mini chart inside the stat card?

The cleanest approach is to pass a chart render prop or slot. Inside the card layout, reserve a fixed-height area (48px works well) and render a lightweight SVG sparkline there. Libraries like recharts with <Sparkline> or a hand-rolled SVG path keep the bundle small. Avoid full chart library imports just for a sparkline.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Pricing Card Variants: 7 SaaS Designs That Increase ConversionsChart Dashboard in React: Recharts, Filters, Export to PNGDark Support Ticket UI: Help Desk Interface ComponentsNeon Dashboard Widgets: Dark Analytics UI with Glow Effects