Retour au catalogue

Portfolio Cursor Follow

Liste de projets avec image preview qui suit le curseur via useSpring. L'image apparait et disparait avec AnimatePresence. Interaction Awwwards-level.

portfoliocomplex Both Responsive a11y
minimaleditorialagencyportfoliostacked
Theme

Créer une liste de projets portfolio avec preview qui suit le curseur en React

Une liste portfolio avec preview qui suit le curseur en React capte les coordonnées du pointeur via useMotionValue de Framer Motion, les lisse avec useSpring, puis positionne une carte preview absolue à ces coordonnées pour qu'elle flotte au-dessus de la ligne survolée. AnimatePresence gère l'entrée et la sortie en scale+opacité.

  • Stack : React 18, Framer Motion 11, lucide-react, ~190 lignes, zéro dépendance supplémentaire.
  • Config spring : stiffness 200, damping 25, mass 0.5, donne à la carte un retard naturel derrière le pointeur.
  • Les lignes non survolées passent à 0.3 d'opacité pour garder le focus sur l'élément actif.
  • Mobile : pas de pointeur sur écran tactile ; le composant affiche une liste simple sans preview sur ces appareils.
  • La carte preview utilise pointerEvents:none pour ne jamais bloquer les clics sur les lignes.

Ce composant affiche une liste de projets numérotés où survoler une ligne fait apparaître une carte flottante qui suit le curseur dans toute la section. C'est le type d'interaction qu'on voit sur les sites Awwwards et les portfolios d'agences haut de gamme. Les autres lignes passent à une opacité quasi nulle dès qu'une ligne est active, concentrant toute l'attention visuelle sur le projet survolé.

Anatomie

La section se décompose en trois couches rendues dans un unique conteneur relative. À la base, une liste décalée d'éléments anchor, chacun affichant un index en zéro-padding monospace, un grand titre de projet, un label de catégorie, une année et une icône ArrowUpRight. Par-dessus, une motion.div absolue joue le rôle de carte preview, 280 × 200 px, arrondie, colorée avec la propriété color du projet actif, centrée sur la position curseur animée en spring. L'en-tête au-dessus de la liste est une motion.div séparée avec un déclencheur whileInView.

Comment ça marche

Le handler onMouseMove sur le conteneur lit clientX/Y et soustrait le bounding rect du conteneur pour obtenir des coordonnées relatives à la section. Elles alimentent deux instances useMotionValue (mouseX, mouseY), chacune passée dans useSpring avec stiffness 200, damping 25, mass 0.5. La carte preview assigne ses props motion x et y directement à ces valeurs spring, ce qui lui donne un léger retard sur le pointeur. AnimatePresence enveloppe la carte et pilote un scale 0.8 → 1 à l'entrée et 1 → 0.8 à la sortie, créant un pop satisfaisant. L'index de la ligne active vit dans un useState classique pour que chaque item calcule sa propre opacité : 1 si actif ou si rien n'est survolé, 0.3 sinon.

Comment le coder en React

  1. Initialiser les motion values et le spring

    Crée deux instances useMotionValue pour la position brute du pointeur, puis enveloppe chacune dans useSpring avec la même config. Garde un activeIndex dans useState. Ces trois éléments constituent tout l'état dont le composant a besoin.

    const mouseX = useMotionValue(0);
    const mouseY = useMotionValue(0);
    const SPRING = { stiffness: 200, damping: 25, mass: 0.5 };
    const springX = useSpring(mouseX, SPRING);
    const springY = useSpring(mouseY, SPRING);
    const [activeIndex, setActiveIndex] = useState<number | null>(null);
  2. Suivre le pointeur relatif au conteneur

    Attache onMouseMove au div wrapper. Soustrait getBoundingClientRect().left/top des coordonnées client brutes pour que la carte s'ancre à la section et non au viewport. Mets à jour mouseX et mouseY ; les springs se recalculent automatiquement.

    function handleMouseMove(e: React.MouseEvent) {
      const rect = e.currentTarget.getBoundingClientRect();
      mouseX.set(e.clientX - rect.left);
      mouseY.set(e.clientY - rect.top);
    }
  3. Afficher la carte preview flottante avec AnimatePresence

    Place une motion.div dans AnimatePresence. Mets position absolute, pointerEvents none, et passe springX/springY aux props motion x/y. Le transform: translate(-50%, -110%) décale la carte pour qu'elle flotte au-dessus du curseur plutôt que de le recouvrir.

    <AnimatePresence>
      {activeIndex !== null && (
        <motion.div
          initial={{ opacity: 0, scale: 0.8 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={{ opacity: 0, scale: 0.8 }}
          style={{
            position: "absolute",
            x: springX,
            y: springY,
            transform: "translate(-50%, -110%)",
            pointerEvents: "none",
            background: projects[activeIndex].color,
          }}
        />
      )}
    </AnimatePresence>
  4. Atténuer les lignes inactives

    Sur chaque élément de liste, mets l'opacité en inline : 1 quand rien n'est survolé ou quand cet item est l'actif, 0.3 sinon. Une transition CSS classique sur opacity suffit ici ; pas besoin de spring pour cette partie.

    opacity: activeIndex !== null && activeIndex !== i ? 0.3 : 1,
    transition: "opacity 0.3s ease",

Quand l'utiliser

Ce pattern a sa place sur un portfolio créatif ou la homepage d'une agence, là où l'objectif est de rendre une liste de projets vivante. Il fonctionne mieux avec 4 à 8 items ; une liste plus longue épuise l'interaction. À éviter sur les listings e-commerce ou tout tableau où la vitesse de lecture prime sur l'effet. Prévois toujours un fallback pour les écrans tactiles en masquant la carte preview quand aucun pointeur n'est disponible.

Utilisé par

  • Locomotive, L'agence montréalaise utilise des vignettes de projets qui suivent le curseur comme interaction signature dans sa liste de case studies.
  • Aristide Benoist, Développeur créatif français dont le portfolio a popularisé ce pattern : une image de projet flottante qui suit le pointeur sur une liste en texte pur.
  • Fantasy, Le studio de design produit utilise des previews de projets au survol dans son index de travaux, faisant apparaître couleur et vignette sans changer de page.
  • Superhuman, Des surfaces réactives au curseur et des états hover qui récompensent le mouvement précis du pointeur jalonnent leurs pages marketing.

FAQ

Pourquoi utiliser useSpring plutôt que de positionner directement ?

Positionner directement cale la carte sur le curseur de façon instantanée, ce qui paraît mécanique. useSpring ajoute de l'inertie pour que la carte traîne légèrement et dépasse un peu, simulant un poids physique. Stiffness 200 et damping 25 trouvent l'équilibre entre réactivité et fluidité.

Comment remplacer l'icône placeholder par une vraie image ?

Ajoute une propriété image à l'interface Project, puis rends une Image Next.js (ou un simple tag img) dans la carte preview à la place de l'icône Lucide. Garde object-fit:cover et assure-toi que les dimensions de la carte correspondent à ton ratio cible.

Est-ce que ça fonctionne avec la navigation clavier ?

Les items de la liste sont des balises anchor qui reçoivent le focus via Tab. La carte preview se déclenche uniquement au survol, donc les utilisateurs clavier voient la liste sans la carte flottante. Pour la parité, ajoute un handler onFocus qui définit activeIndex et positionne la carte près de la ligne focalisée.

Peut-on aussi animer le texte de la ligne au survol ?

Oui. Convertis le motion.a en variant whileHover ou ajoute un motion.span imbriqué sur le titre avec un léger translate x. Garde l'animation subtile : la carte preview est déjà l'événement visuel dominant au survol, donc des animations de titre concurrentes diluent l'effet.

"use client";

import { AnimatePresence, motion, useMotionValue, useSpring } from "framer-motion";
import { ArrowUpRight, Image } from "lucide-react";
import { useState } from "react";

interface Project {
  title: string;
  category: string;
  year: string;
  color: string;
}

interface PortfolioCursorFollowProps {
  title?: string;
  subtitle?: string;
  projects?: Project[];
}

const EASE = [0.16, 1, 0.3, 1] as const;
const SPRING = { stiffness: 200, damping: 25, mass: 0.5 };

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Avis

Liste portfolio React avec preview qui suit le curseur, Tuto