Créer une galerie grille 3D avec tilt souris en React et Framer Motion
Une galerie 3D à tilt en React enveloppe une grille CSS dans un conteneur perspective, puis lit la position de la souris au mousemove pour piloter rotateX et rotateY via des springs Framer Motion. Toute la grille pivote d'un bloc comme un plan rigide, donnant de la profondeur à chaque carte sans écouteur par carte.
- Stack : React 18+, Framer Motion 11, lucide-react pour les icônes de placeholder, ~170 lignes au total.
- APIs clés : useMotionValue, useSpring (stiffness 120, damping 20), CSS perspective: 1200px, transformStyle: preserve-3d.
- Les cartes apparaissent dans le viewport via des animations whileInView individuelles (fondu sur l'axe Z, délai échelonné de 50ms par index).
- Zéro logique de tilt par carte : une seule paire de springs pilote l'ensemble du plan de grille, gardant le composant léger.
- Sur mobile le tilt reste à 0,0, aucun fallback nécessaire, la mise en page reste entièrement fonctionnelle.
Gallery 3D Wall est une grille d'images responsive qui réagit au curseur en inclinant l'ensemble du plan de grille dans l'espace 3D. Plutôt que d'appliquer des effets hover carte par carte, tout le layout pivote autour d'un point de fuite commun, donnant à la collection l'aspect d'un panneau physique qu'on examine sous différents angles. Idéal pour les portfolios, les showcases d'agence et toute page produit qui a besoin d'une impression immédiate de soin.
Anatomie
Le composant s'articule en trois couches. Une balise section extérieure gère le padding pleine largeur et les tokens de fond. À l'intérieur, un conteneur contraint contient un header centré (sous-titre en capitales, grand h2). En dessous, une div désignée comme conteneur de perspective accueille une motion.div qui joue le rôle du plan 3D ; cette motion.div affiche la grille CSS en colonnes auto-fill (minWidth 240px). Chaque cellule est une autre motion.div avec sa propre entrée whileInView, qui enveloppe une carte à ratio fixe (4/3) avec couleur de fond, border-radius, ombre portée et icône placeholder centrée.
Comment ça marche
Au mousemove sur le conteneur de perspective, le handler normalise la position du curseur dans une plage -0,5/+0,5 relative aux dimensions du conteneur via getBoundingClientRect. Il multiplie ensuite le décalage horizontal par 8 pour l'angle de rotation Y et le décalage vertical par -6 pour l'angle X. Les deux valeurs alimentent des springs Framer Motion (stiffness 120, damping 20, mass 0,8) afin que la grille suive le curseur avec un retard et un rebond naturels. Au mouseleave, les deux springs reviennent à zéro. Le CSS perspective: 1200px sur le wrapper et transformStyle: preserve-3d sur la grille rendent le tilt visible ; sans preserve-3d la rotation n'a aucune profondeur visuelle.
Comment le coder en React
Mettre en place le wrapper de perspective et les motion values
Crée une div conteneur avec perspective: 1200px. À l'intérieur, affiche une motion.div avec style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}. Connecte rotateX et rotateY à des instances useSpring pour que chaque changement s'anime en douceur.
const SPRING = { stiffness: 120, damping: 20, mass: 0.8 }; const rotateX = useSpring(useMotionValue(0), SPRING); const rotateY = useSpring(useMotionValue(0), SPRING);Lire et normaliser la position de la souris
Attache onMouseMove au conteneur de perspective. Soustrait le bounding rect du conteneur pour obtenir une position locale, puis divise par width/height pour obtenir des valeurs entre 0 et 1. Décale de -0,5 pour que le centre soit 0,0. Multiplie par ton échelle de rotation et pousse dans les springs.
function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const cx = (e.clientX - rect.left) / rect.width - 0.5; const cy = (e.clientY - rect.top) / rect.height - 0.5; rotateY.set(cx * 8); rotateX.set(cy * -6); }Réinitialiser au départ de la souris
Ajoute un handler onMouseLeave qui remet les deux springs à 0. Le spring de Framer Motion animera le retour afin que la grille revienne en douceur plutôt que de claquer, préservant le ressenti physique.
function handleMouseLeave() { rotateX.set(0); rotateY.set(0); }Échelonner les entrées de cartes avec whileInView
Enveloppe chaque cellule de la grille dans une motion.div avec initial={{ opacity: 0, z: -60, scale: 0.9 }} et whileInView={{ opacity: 1, z: 0, scale: 1 }}. Passe delay: index * 0.05 dans la transition pour que les cartes apparaissent en cascade plutôt que toutes en même temps.
<motion.div initial={{ opacity: 0, z: -60, scale: 0.9 }} whileInView={{ opacity: 1, z: 0, scale: 1 }} viewport={{ once: true, margin: "-40px" }} transition={{ duration: 0.65, delay: i * 0.05, ease: EASE }} style={{ transformStyle: "preserve-3d" }} >
Quand l'utiliser
Ce pattern est à son avantage sur les homepages de portfolio, les showcases d'agence et les galeries produit où tu veux que la grille elle-même paraisse vivante. Utilise-le quand la collection compte 6 à 12 éléments, moins paraît creux sous l'effet de tilt, plus rend la profondeur moins lisible. Évite-le sur les pages catégorie e-commerce où les utilisateurs parcourent des dizaines d'articles rapidement ; une grille statique est plus rapide à analyser. Sur mobile le tilt ne fait rien, donc assure-toi que le layout statique soit solide seul.
Utilisé par
- Awwwards, Présente des portfolios d'agences qui utilisent couramment les grilles en perspective 3D comme interactions signatures sur les sites primés.
- Dribbble, Les pages portfolio de designers utilisent fréquemment des grilles à tilt au survol pour afficher leurs collections avec de la profondeur.
- Resend, Utilise de subtils décalages de perspective 3D sur les grilles de cartes de son site marketing pour ajouter un caractère tactile.
- Basement Studio, Site d'agence construit autour de grilles 3D réactives à la souris comme signature visuelle centrale.
FAQ
Pourquoi le tilt n'a-t-il aucun effet sur mobile ?
Les écrans tactiles déclenchent des événements touch, pas mousemove, donc les springs ne reçoivent jamais de nouvelles valeurs et restent à 0,0. La grille s'affiche quand même correctement en layout plat ; tu perds simplement la rotation 3D. Si tu veux quelque chose sur mobile, envisage un handler touch-drag qui lit le delta d'un événement touchmove.
Peut-on appliquer le tilt par carte plutôt qu'à toute la grille ?
Oui. Déplace la logique useMotionValue et useSpring dans un composant wrapper de carte et attache onMouseMove/onMouseLeave par carte. L'approche par carte est plus gourmande en CPU avec de nombreux éléments mais donne à chaque carte une profondeur indépendante. Pour les galeries de plus de 8 cartes, l'approche grille entière de ce composant est le choix le plus sûr.
Quel est le rôle de transformStyle: preserve-3d ?
Sans preserve-3d, les éléments enfants sont aplatis dans le plan du parent ; la rotation devient un skew 2D qui perd toute profondeur. Définir transformStyle: preserve-3d sur la motion.div wrapper et sur chaque div de carte indique au navigateur de garder les enfants dans l'espace 3D afin que la caméra de perspective voie une véritable profondeur entre eux.
Comment remplacer les cartes placeholder par de vraies images ?
Ajoute un champ src à l'interface GalleryItem et remplace la div placeholder par un composant Next.js Image (ou une balise img). Garde le conteneur aspect-ratio et overflow: hidden, ils maintiennent la forme de la carte quelle que soit la dimension naturelle de l'image.