Display

CardStat

Stat card with a gradient number and animated counter

Preview

30Components
size
variant
number
label
animated

Code

import css from "./CardStat.module.css";
import { useEffect, useRef, useState } from "react";

type Size = "sm" | "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface CardStatProps {
  size: Size | ResponsiveSize;
  variant: "outline" | "solid";
  number: string | number;
  label: string;
  animated?: boolean;
}

const DURATION = 1500;
const easeOutCubic = (progress: number) => 1 - Math.pow(1 - progress, 3);

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function CardStat({ size, variant, number, label, animated }: CardStatProps) {
  const card = useRef<HTMLDivElement>(null);
  const shown = useRef(0);
  const [displayNumber, setDisplayNumber] = useState<string | number>(
    animated && typeof number === "number" ? 0 : number,
  );
  const [started, setStarted] = useState(false);
  const pending = animated && typeof number === "number" && !started;

  useEffect(() => {
    if (!animated || typeof number !== "number" || !card.current) {
      shown.current = 0;
      setDisplayNumber(number);
      return;
    }

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduce) {
      shown.current = number;
      setDisplayNumber(number);
      setStarted(true);
      return;
    }

    const from = shown.current;
    let raf = 0;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;

        setStarted(true);
        const start = performance.now();

        const tick = (now: number) => {
          const progress = Math.min((now - start) / DURATION, 1);

          shown.current =
            progress < 1 ? from + (number - from) * easeOutCubic(progress) : number;
          setDisplayNumber(progress < 1 ? Math.round(shown.current) : number);

          if (progress < 1) raf = requestAnimationFrame(tick);
        };

        raf = requestAnimationFrame(tick);
        observer.disconnect();
      },
      { threshold: 0.3 },
    );

    observer.observe(card.current);
    return () => {
      observer.disconnect();
      cancelAnimationFrame(raf);
    };
  }, [animated, number]);

  return (
    <div ref={card} className={`${css.card} ${css[variant]} ${getSizeClasses(size)}`}>
      <span className={`${css.card_number} ${pending ? css.pending : ""}`}>
        {pending ? number : displayNumber}
      </span>
      <span className={css.card_label}>{label}</span>
    </div>
  );
}

Props

PropTypeRequiredDescription
size"sm" | "md" | "lg" | ResponsiveSizerequiredCard size (number, label and padding). Fixed or responsive.
variant"outline" | "solid"requiredVisual style: an outline lit from the top, or a solid fill.
numberstring | numberrequiredStat value. As a number it can animate; as a string ("+30") it renders as-is.
labelstringrequiredText under the number.
animatedboolean—Counts from 0 to the value on entering the viewport, then from the current figure to each new one (numbers only; honors reduced-motion).