0 litres d’eau économisés cette année
litres d’eau économisés cette année
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
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.