Créer un carrousel de cards draggable horizontalement en React
Un carrousel de cards draggable en React utilise l'API drag de Framer Motion avec une motion value bornée. Chaque card reçoit la motion value x partagée et calcule sa distance aux bords du viewport pour dériver opacité et scale via useTransform, créant un effet de fondu sur les bords.
- Stack : React 18 + Framer Motion 11 + Lucide React + Tailwind v4, ~270 lignes, zéro dépendance supplémentaire.
- API clés : useMotionValue, useTransform, drag, dragConstraints, dragElastic.
- Barre de progression synchronisée à la position de défilement via une motion value scaleX dérivée.
- Entièrement responsive : les contraintes de drag sont recalculées depuis la largeur DOM réelle au démarrage du drag.
- Natif tactile sur mobile ; pas besoin de pointeur, fonctionne avec un seul geste tactile.
Services Scroll Cards est une section glissable horizontalement qui présente un ensemble de cards de services dans une rangée débordante. Les cards s'estompent et rétrécissent lorsqu'elles atteignent les bords du viewport, signalant visuellement qu'il reste du contenu hors champ. Une barre de progression minimaliste sous la piste reflète la position de défilement en temps réel.
Anatomie
La section se divise en trois zones : un bloc header centré (eyebrow, titre H2, sous-titre) ; une piste de drag pleine largeur en overflow hidden contenant une rangée flex de cards ; et une ligne d'indicateur de progression sous la piste. Chaque card est une colonne de largeur fixe (300px) avec une zone icône en ratio 4:3 en haut, un badge de tag optionnel, un titre en gras, et un paragraphe de description en ton atténué. Toute la rangée flex est enveloppée dans un seul motion.div qui reçoit le prop drag.
Comment ça marche
Une seule motion value x partagée pilote tout. Le motion.div parent est configuré avec drag="x", dragConstraints pointant vers le ref du conteneur et dragElastic={0.08} pour un léger rubber-band aux bords. Chaque ServiceCard reçoit dragX (la même valeur x) et son propre index. À l'intérieur de chaque card, deux appels useTransform lisent la valeur x de façon synchrone : ils calculent la position projetée de la card à l'écran (cardOffset + val + center) et mesurent sa distance aux deux bords du viewport. Quand cette distance passe sous -80px, l'opacité diminue de 1 à 0 sur 120px, et le scale rétrécit de 1 à 0.88 minimum sur 600px. Le scaleX de progression est dérivé de la même façon : un callback useTransform mappe x vers la plage 0..1 en fonction de la contrainte maxLeft calculée.
Comment le coder en React
Crée la motion value partagée et la piste de drag
Déclare un seul useMotionValue(0) pour x dans le composant parent. Attache un ref au div wrapper en overflow hidden, puis passe les deux au motion.div avec drag="x", dragConstraints={trackRef}, et style={{ x }}. Mets dragElastic={0.08} pour que la rangée résiste légèrement au dépassement.
const x = useMotionValue(0); const trackRef = useRef<HTMLDivElement>(null); <div ref={trackRef} className="w-full overflow-hidden"> <motion.div drag="x" dragConstraints={trackRef} dragElastic={0.08} style={{ x, display: "flex", gap: 24 }}> {cards.map((card, i) => <ServiceCard key={card.id} dragX={x} index={i} totalCards={cards.length} />)} </motion.div> </div>Dérive l'opacité et le scale de chaque card depuis la position de drag
Dans chaque card, calcule les positions écran gauche et droite projetées à partir de l'index, de CARD_WIDTH et de CARD_GAP. Ensuite, appelle useTransform sur dragX pour mapper cette distance au bord sur l'opacité et le scale. Les cards entièrement dans le viewport restent à opacité 1 et scale 1 ; celles qui franchissent un bord s'estompent et rétrécissent proportionnellement.
const opacity = useTransform(dragX, (val) => { const pos = index * (CARD_WIDTH + CARD_GAP) + val + center; const distFromEdge = Math.min(pos, viewWidth - pos - CARD_WIDTH); return distFromEdge > -80 ? 1 : Math.max(0, 1 + distFromEdge / 120); }); const scale = useTransform(dragX, (val) => { const pos = index * (CARD_WIDTH + CARD_GAP) + val + center; const distFromEdge = Math.min(pos, viewWidth - pos - CARD_WIDTH); return distFromEdge > -80 ? 1 : Math.max(0.88, 1 + distFromEdge / 600); });Ajoute la barre de progression
Sous la piste de drag, rends une ligne horizontale d'1px et un div enfant avec origin-left. Calcule scaleX via un callback useTransform qui mappe la valeur x courante vers 0..1 : divise le décalage x négatif par maxLeft (la distance de drag maximale). Applique-le au style du div enfant.
const progressScaleX = useTransform(x, () => { const maxLeft = -(totalWidth - trackRef.current!.offsetWidth); if (maxLeft >= 0) return 1; return Math.max(0, Math.min(1, 1 - x.get() / maxLeft)); }); <motion.div className="h-full origin-left" style={{ scaleX: progressScaleX, backgroundColor: "var(--color-accent)" }} />Borne x au démarrage du drag pour les layouts responsive
La contrainte maxLeft dépend de la largeur réelle rendue du conteneur, qui change au redimensionnement. Lis-la dans onDragStart depuis le DOM réel via trackRef.current.offsetWidth. Si la valeur x courante est déjà hors de la plage valide, ce qui peut arriver après un redimensionnement, borne-la avant que Framer Motion applique ses propres contraintes.
onDragStart={() => { const viewW = trackRef.current!.offsetWidth; const maxLeft = -(totalWidth - viewW); const cur = x.get(); if (cur > 0) x.set(0); if (cur < maxLeft) x.set(maxLeft); }}
Quand l'utiliser
Ce pattern convient quand tu as cinq services ou plus qui ne tiennent pas lisiblement dans une grille aux breakpoints courants. Il fonctionne bien sur les sites d'agences, les pages de features SaaS, et les landings produit où chaque service est suffisamment distinct pour mériter sa propre card. À éviter quand les éléments sont très comparables et que l'utilisateur doit les parcourir côte à côte, une grille statique sert mieux cet usage. Sur de très grands écrans où toutes les cards tiennent, l'interaction de drag devient inerte ; envisage de masquer la barre de progression dans ce cas pour éviter une UI déroutante.
Utilisé par
- Stripe, Rangées de features produit en drag-to-scroll horizontal sur ses pages Payments et produits.
- Shopify, Rangées de cards défilables pour les points forts des features sur sa page marketing.
- Notion, Carrousels de features draggable horizontalement sur plusieurs landings produit et cas d'usage.
- Webflow, Sections de cards draggables au toucher présentant les capacités produit dans une rangée défilable.
FAQ
Fonctionne-t-il sur écrans tactiles et mobile ?
Oui. L'API drag de Framer Motion gère nativement les événements souris et tactiles, donc le même geste de drag fonctionne sur iOS et Android sans code supplémentaire.
Pourquoi useTransform plutôt que CSS overflow:scroll ?
CSS overflow:scroll ne peut pas piloter l'opacité et le scale par card en fonction de la position sans JavaScript. useTransform lit la motion value de drag de façon synchrone à chaque frame, permettant à chaque card de réagir à sa position écran précise sans aucun recalcul de layout.
Comment mettre à jour les contraintes de drag après un redimensionnement ?
L'approche la plus propre est de recalculer maxLeft dans onDragStart en lisant trackRef.current.offsetWidth à ce moment, plutôt que de le stocker en state. Tu peux aussi ajouter un ResizeObserver sur le ref de la piste pour rappeler x.set() dans la plage valide à chaque changement de largeur du conteneur.
Peut-on ajouter un comportement de snap sur les cards ?
Framer Motion n'a pas de points de snap intégrés pour le drag, mais tu peux l'implémenter avec onDragEnd : calcule l'index de card le plus proche depuis la valeur x finale, puis anime x vers l'offset de cette card via animate(x, targetOffset, { type: 'spring' }).