0 litres d’eau économisés cette année

litres d’eau économisés cette année

React + MotionTexte et défilement

Chiffre compteur kilométrique

Un très grand chiffre dont les rouleaux tournent avec un ressort jusqu’à leur valeur à l’arrivée à l’écran, zéros de tête estompés.

Réglages

128450

Code

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

interface OdometerCounterProps {
  value?: number;
  label?: string;
  color?: string;
}

const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

function Digit({ d, delay, faded, reduce }: { d: number; delay: number; faded: boolean; reduce: boolean }) {
  return (
    <span
      className="relative inline-block h-[1.1em] w-[0.6em] overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,#000_14%,#000_86%,transparent)] transition-opacity duration-500"
      style={{ opacity: faded ? 0.16 : 1 }}
    >
      <motion.span
        className="absolute inset-x-0 top-0 flex flex-col"
        initial={false}
        animate={{ y: -d * 10 + "%" }}
        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 70, damping: 15, delay }}
      >
        {DIGITS.map((n) => (
          <span key={n} className="flex h-[1.1em] items-center justify-center">
            {n}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

export default function OdometerCounter({
  value = 128450,
  label = "litres d’eau économisés cette année",
  color = "#ffb547",
}: OdometerCounterProps) {
  const ref = useRef<HTMLDivElement>(null);
  const seen = useInView(ref, { once: true, amount: 0.5 });
  const reduce = useReducedMotion() ?? false;
  const [n, setN] = useState(0);
  const [bump, setBump] = useState<{ id: number; amount: number } | null>(null);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

  useEffect(() => {
    if (seen) setN(Math.max(0, Math.round(value)));
  }, [seen, value]);
  useEffect(() => () => clearTimeout(timer.current), []);

  const add = () => {
    const amount = 100 + Math.floor(Math.random() * 2400);
    setN((x) => x + amount);
    setBump({ id: Date.now(), amount });
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setBump(null), 1400);
  };

  const target = String(Math.max(0, Math.round(value)));
  const padded = String(n).padStart(target.length, "0");
  const lead = padded.search(/[1-9]/);
  const cells: ({ kind: "digit"; d: number; faded: boolean } | { kind: "sep" })[] = [];
  [...padded].forEach((ch, i) => {
    const fromRight = padded.length - i;
    cells.push({ kind: "digit", d: Number(ch), faded: lead === -1 ? fromRight > 1 : i < lead });
    if (fromRight > 1 && (fromRight - 1) % 3 === 0) cells.push({ kind: "sep" });
  });
  let k = 0;

  return (
    <div ref={ref} className="w-fit max-w-full">
      <p className="sr-only" aria-live="polite">
        {n.toLocaleString("es-ES")} {label}
      </p>
      <div className="relative">
        <AnimatePresence>
          {bump ? (
            <motion.span
              key={bump.id}
              initial={{ opacity: 0, y: 8, scale: 0.9 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={{ opacity: 0, y: -10 }}
              transition={{ type: "spring", stiffness: 420, damping: 28 }}
              aria-hidden="true"
              className="absolute -top-8 left-0 rounded-full px-2.5 py-0.5 text-[13px] font-semibold tabular-nums text-[#1d1d1f]"
              style={{ background: color }}
            >
              +{bump.amount.toLocaleString("es-ES")}
            </motion.span>
          ) : null}
        </AnimatePresence>
        <button
          type="button"
          onClick={add}
          aria-label={"Ajouter à " + label}
          className="flex cursor-pointer items-center text-[64px] font-semibold leading-none tracking-[-0.04em] tabular-nums text-[#1d1d1f] dark:text-[#f5f5f7] sm:text-[76px]"
        >
          <span aria-hidden="true" className="flex items-center">
            {cells.map((c, i) => {
              if (c.kind === "sep")
                return (
                  <span
                    key={"s" + (cells.length - i)}
                    className="mx-[0.03em] h-[0.12em] w-[0.12em] translate-y-[0.3em] rounded-full"
                    style={{ background: color }}
                  />
                );
              const index = k++;
              return (
                <Digit
                  key={"d" + (cells.length - i)}
                  d={c.d}
                  faded={c.faded}
                  delay={(padded.length - 1 - index) * 0.07}
                  reduce={reduce}
                />
              );
            })}
          </span>
        </button>
      </div>
      <div className="mt-4 flex items-center justify-between gap-4">
        <p className="text-balance text-[15px] leading-snug text-[#86868b]">{label}</p>
        <motion.button
          type="button"
          whileTap={{ scale: 0.88 }}
          onClick={add}
          aria-label="Ajouter"
          className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-black/5 text-[#1d1d1f] transition-colors hover:bg-black/10 dark:bg-white/10 dark:text-[#f5f5f7] dark:hover:bg-white/15"
        >
          <Plus strokeWidth={1.5} className="h-4 w-4" />
        </motion.button>
      </div>
    </div>
  );
}

Prompt pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Texte et défilement

Où l’utiliser

Un compteur de chiffres animé donne du poids à un seul nombre : les litres d’eau économisés par une ONG, les commandes expédiées par une petite boutique, les membres d’une communauté, la somme levée par un financement participatif. Les rouleaux tournent quand la section apparaît, et les zéros estompés suggèrent la marge de progression.

Utilisez-le pour un chiffre, deux au maximum, pas pour toute une grille de statistiques. Les nombres ronds se lisent plus vite que les nombres exacts. Un toucher ajoute au total et relance les rouleaux, amusant en démo ; dans un rapport annuel sérieux, mieux vaut peut-être l’enlever.