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

Cómo crear una lista de proyectos de portfolio con preview que sigue el cursor en React

Una lista de portfolio con preview que sigue el cursor en React capta las coordenadas del puntero con useMotionValue de Framer Motion, las suaviza con useSpring y luego posiciona una tarjeta de preview absoluta en esas coordenadas para que flote sobre la fila con hover. AnimatePresence gestiona la entrada y la salida con scale y opacidad.

  • Stack: React 18, Framer Motion 11, lucide-react, ~190 líneas en total, cero dependencias extra.
  • Config del spring: stiffness 200, damping 25, mass 0.5, da a la tarjeta un retardo natural detrás del puntero.
  • Las filas sin hover bajan a 0.3 de opacidad para mantener el foco en el elemento activo.
  • Nota sobre táctil/móvil: no hay puntero en pantallas táctiles; el componente muestra una lista simple sin preview en esos dispositivos.
  • La tarjeta de preview usa pointerEvents:none para no bloquear nunca los clics sobre las filas de la lista.

Este componente muestra una lista numerada de proyectos donde pasar el cursor sobre cualquier fila invoca una tarjeta flotante que persigue el cursor por toda la sección. Es el tipo de interacción que se ve en sitios de Awwwards y portfolios de agencias de alta gama. Los propios elementos de la lista se atenúan hasta casi desaparecer cuando una fila está activa, concentrando todo el peso visual en el proyecto con hover.

Anatomía

La sección se divide en tres capas renderizadas en un único contenedor relative. En la base hay una lista escalonada de elementos anchor, cada uno mostrando un índice con relleno de ceros en monospace, un gran título de proyecto, una etiqueta de categoría, un año y un icono ArrowUpRight. Encima, una motion.div absoluta actúa como tarjeta de preview, de 280 × 200 px, redondeada, coloreada con la propiedad color del proyecto activo, centrada en la posición del cursor animada con spring. La cabecera sobre la lista es una motion.div separada que aparece con un disparador whileInView.

Cómo funciona

El handler onMouseMove del contenedor lee clientX/Y y resta el bounding rect del contenedor para obtener coordenadas relativas a la sección. Estas alimentan dos instancias useMotionValue (mouseX, mouseY), cada una pasada por useSpring con stiffness 200, damping 25, mass 0.5. La tarjeta de preview asigna sus props motion x e y directamente a esos valores del spring, así la tarjeta siempre queda un poco por detrás del puntero. AnimatePresence envuelve la tarjeta y controla un scale de 0.8 a 1 al entrar y de 1 a 0.8 al salir, creando un pop muy agradable. El índice de la fila activa vive en un useState corriente para que cada elemento de la lista calcule su propia opacidad: 1 cuando está activo o cuando no hay nada con hover, 0.3 en caso contrario.

Cómo crearlo en React

  1. Configurar los motion values y el spring

    Crea dos instancias useMotionValue para la posición cruda del puntero y luego envuelve cada una en useSpring con la misma config. Mantén un activeIndex en useState. Estas tres piezas son todo el estado que necesita este componente.

    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. Seguir el puntero relativo al contenedor

    Añade onMouseMove al div contenedor. Resta getBoundingClientRect().left/top de las coordenadas cliente crudas para que la tarjeta se ancle a la sección y no al viewport. Actualiza mouseX y mouseY; los springs se recalculan automáticamente.

    function handleMouseMove(e: React.MouseEvent) {
      const rect = e.currentTarget.getBoundingClientRect();
      mouseX.set(e.clientX - rect.left);
      mouseY.set(e.clientY - rect.top);
    }
  3. Renderizar la tarjeta de preview flotante con AnimatePresence

    Coloca una motion.div dentro de AnimatePresence. Pon position absolute, pointerEvents none y pasa springX/springY a las props motion x/y. El transform: translate(-50%, -110%) desplaza la tarjeta para que flote por encima del cursor en lugar de solaparse con él.

    <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. Atenuar las filas inactivas

    En cada elemento de la lista, define la opacidad inline: 1 cuando no hay nada con hover o cuando este elemento es el activo, 0.3 en caso contrario. Una transición CSS normal sobre opacity basta aquí; no hace falta spring para esta parte.

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

Cuándo usarlo

Este patrón se gana su sitio en un portfolio creativo o en la homepage de una agencia, donde el objetivo es que una lista de proyectos se sienta viva. Funciona mejor con 4 a 8 elementos; una lista más larga agota la interacción. Evítalo en listados de e-commerce o en cualquier tabla donde la velocidad de lectura importe más que el efecto. Ofrece siempre un fallback para pantallas táctiles ocultando la tarjeta de preview cuando no hay puntero disponible.

Usado por

  • Locomotive, La agencia de Montreal usa miniaturas de proyectos que siguen el cursor como interacción distintiva en su lista de case studies.
  • Aristide Benoist, Desarrollador creativo francés cuyo portfolio popularizó este patrón: una imagen de proyecto flotante que persigue el puntero sobre una lista de texto puro.
  • Fantasy, El estudio de diseño de producto usa previews de proyectos con hover en su índice de trabajos, haciendo aparecer color y miniatura sin cambiar de página.
  • Superhuman, Superficies reactivas al cursor y estados hover que premian el movimiento preciso del puntero aparecen por todas sus páginas de marketing.

FAQ

¿Por qué usar useSpring en lugar de fijar la posición directamente?

Fijar la posición directamente pega la tarjeta al cursor de forma instantánea, lo que resulta mecánico. useSpring añade inercia para que la tarjeta se retrase un poco y sobrepase ligeramente, imitando un peso físico. Stiffness 200 y damping 25 dan con el punto justo entre respuesta y fluidez.

¿Cómo reemplazo el icono placeholder por una imagen real?

Añade una propiedad image a la interface Project y luego renderiza un Image de Next.js (o un simple tag img) dentro de la tarjeta de preview en lugar del icono de Lucide. Mantén object-fit:cover y asegúrate de que las dimensiones de la tarjeta coincidan con tu relación de aspecto.

¿Funciona esto con la navegación por teclado?

Los elementos de la lista son etiquetas anchor, así que reciben el foco con Tab. La tarjeta de preview solo aparece con hover, por lo que los usuarios de teclado ven la lista simple sin la tarjeta flotante. Si quieres paridad, añade un handler onFocus que fije activeIndex y posicione la tarjeta cerca de la fila enfocada.

¿Puedo animar también el texto de la fila con hover?

Sí. Convierte el motion.a en una variante whileHover o añade un motion.span anidado en el título con un ligero translate en x. Mantén la animación sutil: la tarjeta de preview ya es el evento visual dominante en el hover, así que animaciones de título que compitan diluyen el efecto.

"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

Reseñas

Lista de portfolio React con preview que sigue el cursor,