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>
);
}