Retour au catalogue
Stats Animated Counter
Compteurs animes qui s'incrementent au scroll avec useInView. Effet visuel impactant pour mettre en avant des chiffres cles.
statsmedium Both Responsive a11y
corporateboldsaasagencyuniversalgrid
Theme
"use client";
import { useEffect, useState, useRef } from "react";
import { motion, useInView } from "framer-motion";
interface StatItem {
id: string;
value: number;
suffix?: string;
prefix?: string;
label: string;
}
interface StatsAnimatedCounterProps {
badge?: string;
title?: string;
subtitle?: string;
stats: StatItem[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
const DURATION_MS = 1600;
const FRAME_RATE = 30;
function CounterValue({ stat, inView }: { stat: StatItem; inView: boolean }) {
const [count, setCount] = useState(0);
useEffect(() => {
if (!inView) return;
let start = 0;
const increment = stat.value / (DURATION_MS / (1000 / FRAME_RATE));
const timer = setInterval(() => {
start += increment;
if (start >= stat.value) {
setCount(stat.value);
clearInterval(timer);
} else {
setCount(Math.floor(start));
}
}, 1000 / FRAME_RATE);
return () => clearInterval(timer);
}, [inView, stat.value]);
return (
<span>
{stat.prefix}
{count.toLocaleString("fr-FR")}
{stat.suffix}
</span>
);
}
export default function StatsAnimatedCounter({
badge,
title,
subtitle,
stats,
}: StatsAnimatedCounterProps) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "-80px" });
return (
<section
style={{
paddingTop: "var(--section-padding-y, 6rem)",
paddingBottom: "var(--section-padding-y, 6rem)",
backgroundColor: "var(--color-background)",
}}
>
<div
style={{
maxWidth: "var(--container-max-width, 72rem)",
margin: "0 auto",
padding: "0 var(--container-padding-x, 1.5rem)",
}}
>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.5, ease: EASE }}
style={{ textAlign: "center", marginBottom: "3.5rem" }}
>
{badge && (
<span
style={{
display: "inline-block",
fontSize: "0.75rem",
fontWeight: 500,
letterSpacing: "0.05em",
textTransform: "uppercase",
padding: "0.25rem 0.75rem",
borderRadius: "var(--radius-full, 9999px)",
border: "1px solid var(--color-border)",
color: "var(--color-accent)",
marginBottom: "1rem",
}}
>
{badge}
</span>
)}
{title && (
<h2
style={{
fontSize: "clamp(1.875rem, 3vw, 2.5rem)",
fontWeight: 700,
letterSpacing: "-0.025em",
color: "var(--color-foreground)",
lineHeight: 1.2,
}}
>
{title}
</h2>
)}
{subtitle && (
<p
style={{
marginTop: "0.75rem",
fontSize: "1rem",
color: "var(--color-foreground-muted)",
maxWidth: "32rem",
marginLeft: "auto",
marginRight: "auto",
lineHeight: 1.6,
}}
>
{subtitle}
</p>
)}
</motion.div>
{/* Stats grid */}
<div
ref={ref}
style={{
display: "grid",
gridTemplateColumns: `repeat(${stats.length > 4 ? 4 : stats.length}, 1fr)`,
gap: "2rem",
textAlign: "center",
}}
>
{stats.map((stat, i) => (
<motion.div
key={stat.id}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.45, delay: i * 0.1, ease: EASE }}
style={{
padding: "2rem 1rem",
borderRadius: "var(--radius-xl, 1.5rem)",
border: "1px solid var(--color-border)",
backgroundColor: "var(--color-background-card)",
}}
>
<p
style={{
fontSize: "clamp(2rem, 4vw, 3rem)",
fontWeight: 700,
color: "var(--color-accent)",
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
}}
>
<CounterValue stat={stat} inView={inView} />
</p>
<p
style={{
marginTop: "0.5rem",
fontSize: "0.875rem",
color: "var(--color-foreground-muted)",
}}
>
{stat.label}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}