How to build a portfolio project list with cursor-following preview in React
A cursor-follow portfolio list in React tracks pointer coordinates with Framer Motion's useMotionValue, smooths them with useSpring, then positions an absolute preview card at those coordinates so it floats above whichever row is hovered. AnimatePresence handles the scale-and-fade on entry and exit.
- Stack: React 18, Framer Motion 11, lucide-react, ~190 lines total, zero extra deps.
- Spring config: stiffness 200, damping 25, mass 0.5, gives the card a natural lag behind the pointer.
- Non-hovered rows fade to 0.3 opacity so focus stays on the active item.
- Touch/mobile caveat: there is no pointer on touch screens; the component renders a plain list with no preview on those devices.
- The preview card uses pointerEvents:none so it never blocks clicks on the list rows.
This component renders a numbered project list where hovering any row summons a floating card that chases the cursor across the section. It is the kind of interaction you see on Awwwards sites and high-end agency portfolios. The list items themselves dim to near-invisible when one row is active, pulling all visual weight onto the hovered project.
Anatomy
The section splits into three layers rendered in a single relative container. At the bottom sits a staggered list of anchor elements, each showing a zero-padded index in monospace, a large project title, a category label, a year, and an ArrowUpRight icon. On top of that, an absolute motion.div acts as the preview card, 280 × 200 px, rounded, colored with the active project's color property, centered on the spring-animated cursor position. The header above the list is a separate motion.div that fades in with a whileInView trigger.
How it works
The onMouseMove handler on the container reads clientX/Y and subtracts the container's bounding rect to get section-relative coordinates. These feed two useMotionValue instances (mouseX, mouseY), each passed through useSpring with stiffness 200, damping 25, mass 0.5. The preview card sets its x and y motion props directly to those spring values, so the card always slightly trails the pointer. AnimatePresence wraps the card and drives scale 0.8 → 1 on enter and 1 → 0.8 on exit, creating a satisfying pop. The active row index lives in a plain useState so each list item can compute its own opacity: 1 when active or when nothing is hovered, 0.3 otherwise.
How to build it in React
Set up motion values and the spring
Create two useMotionValue instances for raw pointer position, then wrap each in useSpring with the same config. Keep an activeIndex in useState. These three pieces are all the state this component needs.
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);Track the pointer relative to the container
Attach onMouseMove to the wrapper div. Subtract getBoundingClientRect().left/top from the raw client coordinates so the card anchors to the section, not the viewport. Set mouseX and mouseY; the springs update automatically.
function handleMouseMove(e: React.MouseEvent) { const rect = e.currentTarget.getBoundingClientRect(); mouseX.set(e.clientX - rect.left); mouseY.set(e.clientY - rect.top); }Render the floating preview card with AnimatePresence
Place a motion.div inside AnimatePresence. Set position absolute, pointerEvents none, and pass springX/springY to the x/y motion props. The transform: translate(-50%, -110%) offsets the card so it floats above the cursor rather than overlapping it.
<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>Dim inactive rows
On each list item, set opacity inline: 1 when nothing is hovered or when this item is the active one, 0.3 otherwise. A plain CSS transition on opacity is sufficient here; no spring needed for this part.
opacity: activeIndex !== null && activeIndex !== i ? 0.3 : 1, transition: "opacity 0.3s ease",
When to use it
This pattern earns its place on a creative portfolio or agency homepage where the goal is to make a list of projects feel alive. It works best with 4 to 8 items; longer lists exhaust the interaction. Skip it on e-commerce product listings or any table where scanning speed matters more than delight. Always provide a fallback for touch devices by hiding the preview card when no pointer is available.
Used by
- Locomotive, The Montréal agency uses cursor-following project thumbnails as a signature interaction across its case study list.
- Aristide Benoist, French creative developer whose portfolio made this pattern widely copied, with a floating project image chasing the pointer over a plain text list.
- Fantasy, The product design studio uses hover-reveal project previews in its work index, letting color and thumbnail appear without a page change.
- Superhuman, Cursor-reactive surfaces and hover states that reward careful pointer movement appear throughout their marketing pages.
FAQ
Why use useSpring instead of just setting position directly?
Direct position snaps the card instantly to the cursor, which looks mechanical. useSpring adds inertia so the card slightly lags and overshoots, mimicking physical weight. Stiffness 200 and damping 25 hit the sweet spot between responsiveness and fluidity.
How do I replace the placeholder icon with a real image?
Add an image property to the Project interface, then render a Next.js Image (or a plain img tag) inside the preview card instead of the Lucide icon. Keep object-fit:cover and ensure the card dimensions match your aspect ratio.
Does this work with keyboard navigation?
The list items are anchor elements so they receive focus via Tab. The hover-only preview card does not appear on focus, so keyboard users see the plain list without the floating card. If you want parity, add an onFocus handler that sets activeIndex and positions the card near the focused row.
Can I animate the row text on hover too?
Yes. Convert the motion.a to a whileHover variant or add a nested motion.span on the title with a slight x translate. Keep the animation subtle, the preview card is already the dominant visual event on hover, so competing title animations dilute the effect.