Retour au catalogue

Stats Animated

Compteurs qui s'incrementent au scroll avec typographie serif elegante, barres accent et animation countup.

statsmedium Both Responsive a11y
eleganteditorialagencyportfoliogrid
Theme
"use client";

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

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

interface StatsAnimatedProps {
  title?: string;
  stats?: StatItem[];
}

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

function CountUp({ target, prefix, suffix, inView }: { target: number; prefix?: string; suffix?: string; inView: boolean }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    if (!inView) return;
    let start = 0;
    const duration = 2000;
    const startTime = Date.now();

    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / duration, 1);
      // Ease out cubic
      const eased = 1 - Math.pow(1 - progress, 3);
      const current = Math.round(eased * target);
      setCount(current);
      if (progress < 1) {
        requestAnimationFrame(animate);
      }
    };
    requestAnimationFrame(animate);
  }, [inView, target]);

  return (
    <span>
      {prefix || ""}{count}{suffix || ""}
    </span>
  );
}

export default function StatsAnimated({
  title = "",
  stats = [],
}: StatsAnimatedProps) {
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "-100px" });

  return (
    <section
      style={{
        padding: "var(--section-padding-y-lg) 0",
        background: "var(--color-background)",
      }}
    >
      <div
        ref={ref}
        style={{
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
          textAlign: "center",
        }}
      >
        {title && (
          <motion.h2
            initial={{ opacity: 0, y: 12 }}
            animate={inView ? { opacity: 1, y: 0 } : {}}
            transition={{ duration: 0.5, ease: EASE }}
            style={{
              fontSize: "clamp(1.5rem, 2.5vw, 2.25rem)",
              fontWeight: 700,
              letterSpacing: "-0.02em",
              color: "var(--color-foreground)",
              marginBottom: "4rem",
            }}
          >
            {title}
          </motion.h2>
        )}

        <div
          style={{
            display: "grid",
            gridTemplateColumns: "1fr",
            gap: "3rem 2rem",
          }}
          className="sm:!grid-cols-2 lg:!grid-cols-4"
        >
          {stats.map((stat, i) => (
            <motion.div
              key={stat.label}
              initial={{ opacity: 0, y: 24 }}
              animate={inView ? { opacity: 1, y: 0 } : {}}
              transition={{ duration: 0.6, delay: i * 0.12, ease: EASE }}
              style={{
                display: "flex",
                flexDirection: "column",
                alignItems: "center",
                gap: "0.75rem",
              }}
            >
              <span
                style={{
                  fontFamily: "var(--font-serif)",
                  fontSize: "clamp(3rem, 5vw, 4.5rem)",
                  fontWeight: 400,
                  lineHeight: 1,
                  color: "var(--color-foreground)",
                  fontStyle: "italic",
                }}
              >
                <CountUp
                  target={stat.value}
                  prefix={stat.prefix}
                  suffix={stat.suffix}
                  inView={inView}
                />
              </span>
              <div
                style={{
                  width: "24px",
                  height: "2px",
                  background: "var(--color-accent)",
                  borderRadius: "1px",
                }}
              />
              <span
                style={{
                  fontSize: "0.875rem",
                  fontWeight: 500,
                  color: "var(--color-foreground-muted)",
                }}
              >
                {stat.label}
              </span>
            </motion.div>
          ))}
        </div>
      </div>
    </section>
  );
}

Avis

Stats Animated — React Stats Section — Incubator