Créer un portfolio React avec cartes empilées au défilement
Un portfolio React à cartes empilées utilise position:sticky avec des décalages top incrementaux par carte, de sorte que chaque carte se fixe à une profondeur différente au défilement. useScroll et useTransform de Framer Motion animent le scale de 0.88 à 1 et l'opacité de 0.4 à 1 à l'entrée de chaque carte dans le viewport, créant un effet de deck en couches.
- Stack : React 18 + Framer Motion 11 + Lucide React, ~190 lignes, zéro dépendance supplémentaire.
- APIs clés : useScroll (target + offset), useTransform, position:sticky par carte.
- Accepte 3 à 5 projets ; au-delà de 5, la profondeur de pile devient gênante sur les petits écrans.
- Accessible : les cartes sont un balisage standard de type article ; l'animation est décorative et ne masque pas le contenu.
- Responsive : la grille 2 colonnes à l'intérieur de chaque carte s'adapte naturellement sur les viewports étroits.
Portfolio Stacked Scroll est une section React qui présente les projets sous forme de deck empilé : chaque carte se fixe via position:sticky à un décalage progressivement plus profond, de sorte que défiler dans la liste ressemble à soulever des cartes d'un tas. Les transforms scale et opacité de Framer Motion offrent une révélation satisfaisante à chaque carte qui entre dans le viewport. Pour les agences et freelances qui veulent montrer leur travail sans une grille classique, ce pattern communique le soin du détail sans nécessiter un CMS personnalisé.
Anatomie
La section externe contient un conteneur centré avec un bloc d'en-tête (label surtitre + H2) et la liste de cartes en dessous. Chaque carte est un composant StackedCard enveloppant une motion.div dans une div sticky. La div sticky porte le décalage top (base 80px plus 40px par index) et une marge inférieure de 20vh entre les cartes sauf la dernière. Dans la motion.div, une grille CSS à deux colonnes dispose un placeholder couleur 16:10 à gauche et les métadonnées du projet (catégorie, titre, description, lien CTA) à droite.
Comment ça marche
Chaque StackedCard possède une ref attachée à sa div sticky externe. useScroll de Framer Motion lit le scrollYProgress pour cet élément avec l'offset ['start end', 'start start'], c'est-à-dire 0 quand le bas de la carte touche le bas du viewport et 1 quand le haut de la carte s'aligne avec le haut du viewport. useTransform mappe cette progression sur le scale (0.88 à 1) et l'opacité (0.4 à 0, 0.8 à 0.5, 1 à 1). La transition y de whileInView (60px à 0, once:true) gère l'animation d'entrée initiale indépendamment de la progression du scroll. Le décalage top sticky augmente de 40px par index de carte, créant la profondeur visuelle du deck sans calculs de layout en JavaScript.
Comment le coder en React
Configurer chaque carte comme conteneur sticky
Rends chaque carte dans une div avec position:sticky et une valeur top calculée. Le décalage de base est 80px et chaque carte suivante ajoute 40px, de sorte que les cartes se superposent visuellement. Définis marginBottom à 20vh pour toutes les cartes sauf la dernière afin de laisser assez de distance de scroll pour que l'animation se joue.
style={{ position: "sticky", top: `calc(80px + ${index * 40}px)`, marginBottom: index < total - 1 ? "20vh" : 0, zIndex: index, }}Suivre la progression de scroll par carte avec useScroll
Attache une ref au conteneur sticky et passe-la comme cible de useScroll. L'option offset contrôle quand scrollYProgress commence et se termine : 'start end' se déclenche quand le bas de la carte entre en bas du viewport, et 'start start' quand le haut de la carte atteint le haut. Cela donne une plage complète 0 à 1 par carte.
const ref = useRef<HTMLDivElement>(null); const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "start start"], });Mapper la progression sur le scale et l'opacité
Utilise useTransform pour dériver une valeur de scale (0.88 quand la carte apparaît, 1 quand elle est pleinement visible) et une valeur d'opacité avec une courbe à trois images-clés pour que la carte monte en fondu. Applique les deux à la motion.div qui enveloppe le contenu de la carte.
const scale = useTransform(scrollYProgress, [0, 1], [0.88, 1]); const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0.4, 0.8, 1]); <motion.div style={{ scale, opacity }} initial={{ y: 60 }} whileInView={{ y: 0 }} viewport={{ once: true }} transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }} >Construire le layout de carte avec une grille 2 colonnes
Dans la motion.div, utilise une grille CSS avec des colonnes 1.2fr 1fr et un gap de 2.5rem. La colonne gauche contient le placeholder d'image du projet (ratio 16:10, couleur de fond issue des données), la colonne droite le label catégorie, le titre du projet, le paragraphe de description et un lien CTA. Toutes les couleurs référencent des propriétés CSS custom des tokens de thème pour que le composant fonctionne sur les 7 presets.
Quand l'utiliser
Ce pattern convient aux portfolios d'agences, aux pages études de cas freelance et aux showcases de fonctionnalités produit où montrer 3 à 5 éléments séquentiellement crée une dynamique narrative. L'empilement sticky récompense le défilement attentif et signale une exécution premium. À éviter quand tu as plus de 5 éléments (la pile devient trop profonde) ou quand les utilisateurs ont besoin de comparer les projets côte à côte. Sur mobile le stack sticky fonctionne mais les marges de 20vh occupent beaucoup d'espace écran, donc teste sur un vrai appareil avant de livrer.
Utilisé par
- Stripe, Utilise des sections sticky empilées dans ses pages produit pour dérouler des révélations de fonctionnalités séquentielles au scroll.
- Lottiefiles, Emploie l'empilement de cartes piloté par le scroll pour présenter les étapes d'intégration avec profondeur et mouvement sur ses landing pages.
- Pitch, Empile des cartes de fonctionnalités qui se fixent et passent à l'échelle lors du scroll sur son site marketing.
FAQ
Pourquoi chaque carte a-t-elle besoin de son propre ref useScroll au lieu d'un listener global ?
useScroll de Framer Motion avec un ref cible suit la progression de scroll relative à cet élément précis, donc chaque carte obtient sa propre plage 0 à 1 liée au moment où elle entre dans le viewport et le remplit. Un listener global unique donnerait le même nombre à toutes les cartes et rendrait les animations indépendantes par carte impossibles.
Comment remplacer le placeholder couleur par une vraie image ?
Remplace la div interne par un composant Next.js Image (ou un img simple) avec objectFit:cover dans le même conteneur à ratio 16:10. Garde overflow:hidden sur le wrapper pour que les coins arrondis découpent correctement l'image. Passe le src de l'image via l'interface Project en plus du champ color existant.
Les cartes se chevauchent visuellement. Comment contrôler l'ordre d'empilement ?
Le zIndex est défini sur la valeur d'index de la carte (0, 1, 2…) pour que les cartes suivantes se posent au-dessus des précédentes. Si tu veux que les cartes précédentes apparaissent au-dessus (un deck inversé), définis zIndex sur total - index.
Le stack sticky fonctionne-t-il dans une modale ou un parent en overflow:hidden ?
Non. position:sticky cesse de fonctionner dès qu'un ancêtre a overflow:hidden ou overflow:auto. Assure-toi que le conteneur de défilement qui pilote l'effet est le body du document ou un conteneur avec overflow:visible jusqu'en haut de l'arbre.