Retour au catalogue

Stats Animated Counter

Compteurs animes qui s'incrementent au scroll avec useInView. Effet visuel impactant pour mettre en avant des chiffres cles.

statsmedium Both Responsive a11y
corporateboldsaasagencyuniversalgrid
Theme
"use client";

import { useEffect, useState, useRef } from "react";
import { motion, useInView } from "framer-motion";

interface StatItem {
  id: string;
  value: number;
  suffix?: string;
  prefix?: string;
  label: string;
}

interface StatsAnimatedCounterProps {
  badge?: string;
  title?: string;
  subtitle?: string;
  stats: StatItem[];
}

const EASE = [0.16, 1, 0.3, 1] as const;
const DURATION_MS = 1600;
const FRAME_RATE = 30;

function CounterValue({ stat, inView }: { stat: StatItem; inView: boolean }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    if (!inView) return;

    let start = 0;
    const increment = stat.value / (DURATION_MS / (1000 / FRAME_RATE));
    const timer = setInterval(() => {
      start += increment;
      if (start >= stat.value) {
        setCount(stat.value);
        clearInterval(timer);
      } else {
        setCount(Math.floor(start));
      }
    }, 1000 / FRAME_RATE);

    return () => clearInterval(timer);
  }, [inView, stat.value]);

  return (
    <span>
      {stat.prefix}
      {count.toLocaleString("fr-FR")}
      {stat.suffix}
    </span>
  );
}

export default function StatsAnimatedCounter({
  badge,
  title,
  subtitle,
  stats,
}: StatsAnimatedCounterProps) {
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "-80px" });

  return (
    <section
      style={{
        paddingTop: "var(--section-padding-y, 6rem)",
        paddingBottom: "var(--section-padding-y, 6rem)",
        backgroundColor: "var(--color-background)",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width, 72rem)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x, 1.5rem)",
        }}
      >
        {/* Header */}
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={{ duration: 0.5, ease: EASE }}
          style={{ textAlign: "center", marginBottom: "3.5rem" }}
        >
          {badge && (
            <span
              style={{
                display: "inline-block",
                fontSize: "0.75rem",
                fontWeight: 500,
                letterSpacing: "0.05em",
                textTransform: "uppercase",
                padding: "0.25rem 0.75rem",
                borderRadius: "var(--radius-full, 9999px)",
                border: "1px solid var(--color-border)",
                color: "var(--color-accent)",
                marginBottom: "1rem",
              }}
            >
              {badge}
            </span>
          )}
          {title && (
            <h2
              style={{
                fontSize: "clamp(1.875rem, 3vw, 2.5rem)",
                fontWeight: 700,
                letterSpacing: "-0.025em",
                color: "var(--color-foreground)",
                lineHeight: 1.2,
              }}
            >
              {title}
            </h2>
          )}
          {subtitle && (
            <p
              style={{
                marginTop: "0.75rem",
                fontSize: "1rem",
                color: "var(--color-foreground-muted)",
                maxWidth: "32rem",
                marginLeft: "auto",
                marginRight: "auto",
                lineHeight: 1.6,
              }}
            >
              {subtitle}
            </p>
          )}
        </motion.div>

        {/* Stats grid */}
        <div
          ref={ref}
          style={{
            display: "grid",
            gridTemplateColumns: `repeat(${stats.length > 4 ? 4 : stats.length}, 1fr)`,
            gap: "2rem",
            textAlign: "center",
          }}
        >
          {stats.map((stat, i) => (
            <motion.div
              key={stat.id}
              initial={{ opacity: 0, y: 16 }}
              whileInView={{ opacity: 1, y: 0 }}
              viewport={{ once: true, margin: "-60px" }}
              transition={{ duration: 0.45, delay: i * 0.1, ease: EASE }}
              style={{
                padding: "2rem 1rem",
                borderRadius: "var(--radius-xl, 1.5rem)",
                border: "1px solid var(--color-border)",
                backgroundColor: "var(--color-background-card)",
              }}
            >
              <p
                style={{
                  fontSize: "clamp(2rem, 4vw, 3rem)",
                  fontWeight: 700,
                  color: "var(--color-accent)",
                  lineHeight: 1,
                  fontVariantNumeric: "tabular-nums",
                }}
              >
                <CounterValue stat={stat} inView={inView} />
              </p>
              <p
                style={{
                  marginTop: "0.5rem",
                  fontSize: "0.875rem",
                  color: "var(--color-foreground-muted)",
                }}
              >
                {stat.label}
              </p>
            </motion.div>
          ))}
        </div>
      </div>
    </section>
  );
}

Avis

Stats Animated Counter — React Stats Section — Incubator