Créer une section témoignages clients avec reveal au scroll en React
Une section témoignages clients React avec reveal au scroll utilise useInView de Framer Motion pour déclencher une animation clip-path sur chaque carte à son entrée dans le viewport, tandis qu'un effet count-up sur la stat clé est piloté par useMotionValue et animate. Chaque carte anime aussi une bordure accent gauche et une teinte de fond au hover via des variants Framer Motion.
- Stack : React 18 + Framer Motion 11 + CSS custom properties, ~250 lignes réparties sur deux fichiers, zéro lib d'icônes.
- Détection scroll : useInView avec once:true et une marge de -80px, l'animation se déclenche juste avant que la carte soit entièrement visible.
- Count-up : animate() de Framer Motion pilote useMotionValue de 0 jusqu'à la valeur cible sur 1,6s avec une courbe easeOut ; supporte les entiers et les flottants à une décimale.
- Accessible : les cartes utilisent blockquote sémantique + attribution auteur ; l'animation clip-path respecte prefers-reduced-motion si géré au niveau du thème.
- Responsive : la grille trois colonnes par carte se réduit naturellement sur écrans étroits via CSS grid.
Customer Stories Scroll est une section empilée de grandes cartes études de cas horizontales, chacune se révélant diagonalement à son entrée dans le viewport. Le motif combine un sweep clip-path, une métrique clé en count-up et un état hover avec une bordure accent gauche animée, bien plus proche des pages études de cas de Linear ou Apple que d'une grille de témoignages générique.
Anatomie
Le composant parent rend un header (badge optionnel, h2, sous-titre) animé avec un simple fade-up au montage, suivi d'une colonne flex verticale de composants StoryCard. Chaque StoryCard est une grille CSS trois colonnes : la colonne gauche contient le texte logo de l'entreprise et une pill industrie ; la colonne centrale affiche la stat animée ; la colonne droite contient le blockquote, l'attribution auteur et un lien optionnel vers l'étude. Une teinte de fond pilotée au hover et une bordure accent gauche en scaleY se superposent à la carte sans décaler son layout.
Comment ça marche
Chaque StoryCard se clippe de droite à gauche avec `clipPath: 'inset(0 100% 0 0)'` au repos, puis anime vers `inset(0 0% 0 0)` quand useInView se déclenche. Les cartes décalent de 120ms par index. Le count-up de stat vit dans un composant AnimatedStat séparé : useInView déclenche un appel Framer Motion animate() qui incrémente un useMotionValue de 0 jusqu'à la valeur cible ; useTransform arrondit en entier ou en une décimale. Les effets hover utilisent des variants Framer Motion sur la motion.div parente, la bordure accent pilote scaleY depuis son origine haute, la teinte s'estompe indépendamment.
Comment le coder en React
Mettre en place le reveal au scroll sur chaque carte
Attache un ref au wrapper de chaque carte et passe-le à useInView avec once:true. Utilise le booléen inView pour basculer entre l'état clipé initial et l'état entièrement révélé. Décale le délai en multipliant l'index de la carte par 0,12.
const ref = useRef<HTMLDivElement>(null); const inView = useInView(ref, { once: true, margin: "-80px" }); <motion.div ref={ref} initial={{ clipPath: "inset(0 100% 0 0)", opacity: 0.6 }} animate={inView ? { clipPath: "inset(0 0% 0 0)", opacity: 1 } : {}} transition={{ clipPath: { duration: 0.75, delay: index * 0.12, ease: [0.22, 1, 0.36, 1] }, opacity: { duration: 0.3, delay: index * 0.12 }, }} />Construire la stat count-up
Crée un composant AnimatedStat dédié. Utilise useMotionValue démarrant à 0, puis appelle animate() dans un useEffect quand inView passe à true. useTransform gère l'arrondi, Math.round pour les entiers, toFixed(1) pour les décimales. Stocke le contrôleur d'animation pour pouvoir l'arrêter au cleanup.
const count = useMotionValue(0); const rounded = useTransform(count, (v) => isFloat ? v.toFixed(1) : Math.round(v).toString() ); useEffect(() => { if (!inView) return; const ctrl = animate(count, numericValue, { duration: 1.6, ease: "easeOut" }); return () => ctrl.stop(); }, [inView]);Ajouter la bordure accent au hover
Enveloppe le contenu de la carte dans une motion.div avec initial='rest' et whileHover='hover'. Définis des variants pour l'overlay de bordure gauche : scaleY passe de 0 à 1 avec transformOrigin fixé à 'top'. Un second variant contrôle l'opacité de la teinte de fond indépendamment avec sa propre durée de transition.
<motion.div initial="rest" whileHover="hover"> {/* Accent border */} <motion.div variants={{ rest: { scaleY: 0 }, hover: { scaleY: 1 } }} transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }} style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 3, background: "var(--color-accent)", transformOrigin: "top" }} /> </motion.div>Brancher la structure de données
Chaque story a besoin d'un id, d'un texte logo, d'un label industrie, d'une chaîne stat numérique (entier ou flottant à une décimale), d'un suffixe optionnel comme '%' ou 'M€', d'un label de stat, d'une citation, auteur, rôle et d'un href optionnel. Garde la stat en string pour qu'AnimatedStat détecte si toFixed(1) s'applique.
interface Story { id: string; logo: string; industry: string; stat: string; // "340", "2.4", "98" statSuffix?: string; // "%", "M€" statLabel: string; quote: string; author: string; role: string; href?: string; }
Quand l'utiliser
Place cette section en milieu ou fin de funnel, après les features ou le pricing, là où des résultats concrets renforcent la décision. Elle fonctionne mieux avec 3 à 5 stories ; en dessous ça paraît mince, au-dessus le rythme du reveal se casse. À éviter sur les pages marketing qui ont déjà une grille de témoignages au-dessus de la ligne de flottaison : deux sections de preuve sociale dos à dos diluent les deux. Sur mobile, la grille trois colonnes par carte devient une colonne unique, ce qui fonctionne mais perd la comparaison rapide entre logo, stat et citation.
Utilisé par
- Linear, Utilise des cartes études de cas en disposition horizontale avec une métrique proéminente, une courte citation et une attribution auteur sur son site marketing.
- Vercel, Les pages clients combinent une stat de performance clé, une citation en une phrase et un logo d'entreprise dans un format de carte scannable.
- Stripe, Entrées études de cas empilées avec tags industrie, résultats numériques et extraits en blockquote, la même hiérarchie d'information qu'utilise ce composant.
- Notion, Les cartes témoignages clients associent une stat titre à un court témoignage et un tag rôle/entreprise, révélés progressivement à mesure que l'utilisateur défile.
FAQ
Pourquoi clip-path plutôt qu'une animation translateX ?
Le reveal clip-path garde la carte dans sa position finale dans le layout dès le départ, sans décalage de mise en page et sans que les éléments voisins ne soient déplacés. Un slide-in translateX nécessite overflow:hidden sur un wrapper ou la carte déborde visuellement de sa cellule de grille.
Puis-je utiliser un nombre réel (pas une string) pour la stat ?
AnimatedStat lit la prop stat comme une string et appelle parseFloat dessus, donc '340' et '2.4' fonctionnent tous les deux. La vérification isFloat cherche un point dans la string originale, c'est pourquoi la prop doit rester une string plutôt qu'un nombre JavaScript, 2,4 et 2 perdraient tous les deux le point.
Comment ajuster le délai décalé entre les cartes ?
Le délai est `index * 0.12` secondes, donc la deuxième carte commence 120ms après la première et la troisième 240ms après. Change le multiplicateur à 0,08 pour une cascade plus serrée ou à 0,2 pour un décalage plus prononcé. Avec plus de 5 cartes, garde le multiplicateur faible sinon les dernières cartes attendent trop longtemps.
Que se passe-t-il avec prefers-reduced-motion ?
Le composant ne vérifie pas prefers-reduced-motion en interne pour l'instant. Pour le respecter, conditionne la prop animate : lis la media query dans un hook custom et saute directement à l'état final si le mouvement réduit est demandé. Le count-up peut aussi être ignoré en animant avec duration:0.