Retour au catalogue

Metrics Animated Counter

Compteurs animes avec defilement de chiffres et effet de machine a sous au scroll.

metricsmedium Both Responsive a11y
boldcorporatesaasecommercecentered
Theme
"use client";

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

interface Counter {
  value: number;
  prefix?: string;
  suffix?: string;
  label: string;
  description?: string;
}

interface MetricsAnimatedCounterProps {
  title?: string;
  subtitle?: string;
  counters?: Counter[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

function formatNumber(n: number): string {
  if (Number.isInteger(n)) {
    return n.toLocaleString("fr-FR");
  }
  return n.toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 2 });
}

function AnimatedNumber({ counter, index }: { counter: Counter; index: number }) {
  const ref = useRef<HTMLDivElement>(null);
  const isInView = useInView(ref, { once: true, margin: "-40px" });
  const [display, setDisplay] = useState("0");

  useEffect(() => {
    if (!isInView) return;
    const duration = 2000;
    const start = performance.now();

    function animate(now: number) {
      const elapsed = now - start;
      const progress = Math.min(elapsed / duration, 1);
      // Ease out cubic
      const eased = 1 - Math.pow(1 - progress, 3);
      const current = eased * counter.value;
      setDisplay(formatNumber(Number(current.toFixed(counter.value % 1 === 0 ? 0 : 2))));
      if (progress < 1) requestAnimationFrame(animate);
    }

    requestAnimationFrame(animate);
  }, [isInView, counter.value]);

  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0, y: 32 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true }}
      transition={{ duration: 0.6, delay: 0.1 * index, ease: EASE }}
      style={{
        textAlign: "center",
        padding: "2rem 1.5rem",
        borderRadius: "var(--radius-lg)",
        background: "var(--color-background-card)",
        border: "1px solid var(--color-border)",
      }}
    >
      <div
        style={{
          fontSize: "clamp(2rem, 4vw, 3rem)",
          fontWeight: 900,
          color: "var(--color-foreground)",
          fontVariantNumeric: "tabular-nums",
          lineHeight: 1.1,
          marginBottom: "0.75rem",
        }}
      >
        <span style={{ color: "var(--color-accent)" }}>{counter.prefix}</span>
        {display}
        <span style={{ color: "var(--color-accent)" }}>{counter.suffix}</span>
      </div>
      <h3
        style={{
          fontSize: "0.9375rem",
          fontWeight: 700,
          color: "var(--color-foreground)",
          marginBottom: "0.25rem",
        }}
      >
        {counter.label}
      </h3>
      {counter.description && (
        <p
          style={{
            fontSize: "0.8125rem",
            color: "var(--color-foreground-muted)",
            lineHeight: 1.5,
          }}
        >
          {counter.description}
        </p>
      )}
    </motion.div>
  );
}

export default function MetricsAnimatedCounter({
  title = "Nos chiffres cles",
  subtitle = "En chiffres",
  counters = [],
}: MetricsAnimatedCounterProps) {
  return (
    <section
      style={{
        paddingTop: "var(--section-padding-y)",
        paddingBottom: "var(--section-padding-y)",
        background: "var(--color-background-alt)",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
        }}
      >
        <motion.div
          initial={{ opacity: 0, y: 16 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.5, ease: EASE }}
          style={{ textAlign: "center", marginBottom: "3rem" }}
        >
          <div
            style={{
              display: "inline-flex",
              alignItems: "center",
              gap: "0.5rem",
              marginBottom: "0.75rem",
            }}
          >
            <Hash style={{ width: 16, height: 16, color: "var(--color-accent)" }} />
            <span
              style={{
                fontSize: "0.75rem",
                fontWeight: 600,
                textTransform: "uppercase",
                letterSpacing: "0.1em",
                color: "var(--color-accent)",
              }}
            >
              {subtitle}
            </span>
          </div>
          <h2
            style={{
              fontSize: "clamp(1.75rem, 3vw, 2.5rem)",
              fontWeight: 700,
              color: "var(--color-foreground)",
            }}
          >
            {title}
          </h2>
        </motion.div>

        <div
          style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
            gap: "1.5rem",
          }}
        >
          {counters.map((counter, i) => (
            <AnimatedNumber key={i} counter={counter} index={i} />
          ))}
        </div>
      </div>
    </section>
  );
}

Avis

Metrics Animated Counter — React Metrics Section — Incubator