Les chiffres créent la confiance plus vite que les paragraphes. Quand un visiteur arrive sur votre page et voit "12 000+ clients" ou "99,9% de disponibilité" qui s'incrémentent sous ses yeux, le message passe instantanément. Les sections de compteurs sont parmi les composants à plus fort impact que vous pouvez ajouter à une landing page, et elles sont étonnamment simples à construire en React. Ce guide couvre tout, du compteur basique avec useEffect aux animations Framer Motion déclenchées au scroll.
Compteur basique avec useEffect
Le compteur animé le plus simple utilise useEffect et requestAnimationFrame pour interpoler de 0 à la valeur cible. Aucune dépendance requise :
"use client";
import { useEffect, useRef, useState } from "react";
interface CounterProps {
target: number;
duration?: number; // ms
prefix?: string;
suffix?: string;
}
export function Counter({ target, duration = 2000, prefix = "", suffix = "" }: CounterProps) {
const [count, setCount] = useState(0);
const startTime = useRef<number | null>(null);
useEffect(() => {
function animate(timestamp: number) {
if (!startTime.current) startTime.current = timestamp;
const elapsed = timestamp - startTime.current;
const progress = Math.min(elapsed / duration, 1);
// Ease-out cubic pour une décélération naturelle
const eased = 1 - Math.pow(1 - progress, 3);
setCount(Math.round(eased * target));
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
return () => { startTime.current = null; };
}, [target, duration]);
return (
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{prefix}{count.toLocaleString("fr-FR")}{suffix}
</span>
);
}
La déclaration fontVariantNumeric: "tabular-nums" est critique — sans elle, les chiffres à espacement proportionnel font trembler le nombre horizontalement pendant le décompte. Les chiffres tabulaires garantissent que chaque digit occupe la même largeur.
L'ease-out cubic (1 - Math.pow(1 - progress, 3)) fait démarrer le compteur rapidement et décélérer vers la cible, ce qui paraît naturel. Une interpolation linéaire semble robotique.
Compteur animé avec Framer Motion
Si votre projet utilise déjà Framer Motion, vous pouvez tirer parti de useMotionValue et useTransform pour une approche plus déclarative :
"use client";
import { useEffect } from "react";
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
interface MotionCounterProps {
target: number;
duration?: number;
}
export function MotionCounter({ target, duration = 2 }: MotionCounterProps) {
const motionValue = useMotionValue(0);
const rounded = useTransform(motionValue, (v) => Math.round(v).toLocaleString("fr-FR"));
useEffect(() => {
const controls = animate(motionValue, target, {
duration,
ease: [0.16, 1, 0.3, 1],
});
return controls.stop;
}, [motionValue, target, duration]);
return (
<motion.span style={{ fontVariantNumeric: "tabular-nums" }}>
{rounded}
</motion.span>
);
}
Cette approche est plus propre car Framer Motion gère la boucle d'animation en interne et le hook useTransform met à jour la valeur affichée de manière réactive. L'ease personnalisé [0.16, 1, 0.3, 1] correspond à la courbe spring-like utilisée dans la plupart des composants Incubator.
Compteur déclenché au scroll
Les compteurs de stats doivent commencer à s'animer quand ils entrent dans le viewport — pas au chargement de la page. Un compteur qui termine son animation avant que l'utilisateur ne l'atteigne gaspille l'effet. Utilisez l'API Intersection Observer :
"use client";
import { useEffect, useRef, useState } from "react";
export function useInView(threshold = 0.3) {
const ref = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect(); // animer une seule fois
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, isInView };
}
Puis enveloppez votre compteur :
export function ScrollCounter({ target, ...props }: CounterProps) {
const { ref, isInView } = useInView();
return (
<div ref={ref}>
{isInView ? <Counter target={target} {...props} /> : <span>0</span>}
</div>
);
}
L'observer se déconnecte après la première intersection, donc l'animation ne se déclenche qu'une seule fois. Le threshold: 0.3 signifie que 30% de l'élément doit être visible avant le déclenchement — cela évite que l'animation démarre quand seulement 1px est dans le viewport.
Grille de statistiques avec icônes
Un compteur seul est rarement utile. Le layout standard est un grid à 3 ou 4 colonnes de stats, chacune avec une icône, un nombre et un label :
interface Stat {
icon: React.ReactNode;
value: number;
suffix?: string;
label: string;
}
interface StatsGridProps {
stats: Stat[];
}
export function StatsGrid({ stats }: StatsGridProps) {
const { ref, isInView } = useInView();
return (
<section
ref={ref}
style={{
padding: "var(--section-padding-y) 0",
background: "var(--color-surface)",
}}
>
<div
style={{
maxWidth: "var(--container-max-width)",
margin: "0 auto",
padding: "0 var(--container-padding-x)",
display: "grid",
gridTemplateColumns: `repeat(${Math.min(stats.length, 4)}, 1fr)`,
gap: "2rem",
}}
>
{stats.map((stat, i) => (
<div
key={stat.label}
style={{
textAlign: "center",
padding: "2rem",
}}
>
<div style={{ marginBottom: "1rem", color: "var(--color-accent)" }}>
{stat.icon}
</div>
<div style={{ fontSize: "clamp(2rem, 4vw, 3.5rem)", fontWeight: 700, lineHeight: 1.1 }}>
{isInView ? (
<Counter target={stat.value} suffix={stat.suffix} duration={2000 + i * 200} />
) : (
<span>0</span>
)}
</div>
<p style={{
marginTop: "0.5rem",
fontSize: "0.9375rem",
color: "var(--color-foreground-muted)",
}}>
{stat.label}
</p>
</div>
))}
</div>
</section>
);
}
La duration échelonnée (2000 + i * 200) fait que chaque compteur termine légèrement après le précédent, créant un effet de vague à travers le grid. Ce détail subtil rend la section dynamique sans ajouter de complexité.
Pour le responsive, passez à un grid 2 colonnes sur tablette et une seule colonne sur mobile en utilisant clamp() CSS ou une media query sur gridTemplateColumns.
Compteur de preuve sociale
Les compteurs de preuve sociale combinent des statistiques avec des logos de marques ou des avatars. Le pattern est une bande horizontale — souvent placée juste sous le hero — montrant des métriques comme "Utilisé par 4 200+ équipes" aux côtés d'une pile d'avatars :
<div style={{ display: "flex", alignItems: "center", gap: "1rem", justifyContent: "center" }}>
<div style={{ display: "flex" }}>
{avatars.map((src, i) => (
<img
key={src}
src={src}
alt=""
style={{
width: 36,
height: 36,
borderRadius: "50%",
border: "2px solid var(--color-background)",
marginLeft: i > 0 ? "-10px" : 0,
position: "relative",
zIndex: avatars.length - i,
}}
/>
))}
</div>
<p style={{ fontSize: "0.9375rem", color: "var(--color-foreground-muted)" }}>
Utilisé par <strong><Counter target={4200} suffix="+" /></strong> équipes
</p>
</div>
Les avatars superposés (marginLeft négatif) avec un zIndex décroissant créent le classique stack d'avatars. Le border qui correspond à la couleur de fond crée une séparation nette entre les visages.
Considérations de performance
Les compteurs animés déclenchent des re-renders fréquents pendant la phase de comptage. Trois points à garder en tête :
- Isolez le composant compteur — gardez le
useStateà l'intérieur du composantCounter, pas dans le parent. Cela évite que toute la grille de stats se re-rende à chaque frame. - Utilisez
requestAnimationFrame— n'utilisez jamaissetIntervalpour les animations.rAFse synchronise avec le cycle de peinture du navigateur et se met en pause automatiquement dans les onglets en arrière-plan. - Respectez
prefers-reduced-motion— les utilisateurs qui ont activé la réduction de mouvement doivent voir le nombre final immédiatement, sans animation.
Sections de statistiques prêtes à l'emploi
Le catalogue stats d'Incubator inclut des grilles de compteurs, des barres de preuve sociale, des bandes de logos avec nombres animés et des variantes compatibles mode sombre — toutes déclenchées au scroll et accessibles par défaut. Pour des patterns de preuve sociale plus larges (murs de témoignages, piles d'avatars, badges de notation), parcourez le catalogue de preuve sociale. Chaque section est livrée avec des props TypeScript et des données mock, prête à coller dans votre projet Next.js.