How to build a blog list with floating hover preview in React
A blog hover preview in React tracks the cursor with useMotionValue, stores the hovered row index in a useState, and mounts a floating card (image + excerpt) via AnimatePresence near the pointer. The card scales in from 0.92 and fades out when the row loses focus, giving a smooth editorial feel.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~160 lines, zero extra dependencies.
- Core API: useState, useMotionValue, AnimatePresence, motion.a.
- The preview card uses pointerEvents:none so it never blocks row mouse events.
- Accessible: each row is a real anchor tag; the floating preview is decorative and hidden from screen readers.
- Touch devices get the list without preview (no hover state on mobile).
Blog Hover Preview is a minimal article list where each row, when hovered, spawns a small floating card near the cursor showing the article image and excerpt. The layout stays clean and typographic at rest; the preview card adds depth without cluttering the UI. It is the pattern used by editorial and agency sites that want a high-end feel without heavy carousels.
Anatomy
The section has three structural parts. A motion header holds the section label and an italic serif heading, both entering with an opacity/y entrance animation. Below it, a relative container holds the article rows and the floating preview card. Each row is a motion.a element styled as a flex row: a monospace index number, a bold title (which slides 12px right on hover), a category tag, and an ArrowUpRight icon that rotates 45 degrees on hover. The floating preview card is an absolute motion.div positioned via useMotionValue coordinates, containing a background-image thumbnail and a text block with excerpt and date.
How it works
The whole effect runs from two motion values (mouseX, mouseY) and one piece of state (hoveredIndex). On every mousemove over the row container, getBoundingClientRect converts the pointer coordinates to container-local values and writes them into mouseX/mouseY. These values feed directly into the style left/top of the absolute preview card, so it sticks near the cursor without any re-render. When a row is entered, setHoveredIndex fires and AnimatePresence mounts the card with a scale(0.92) -> scale(1) entrance. On row leave, hoveredIndex goes back to null and AnimatePresence unmounts the card with the reverse exit. The title shift uses a separate motion.h3 that animates its x property between 0 and 12 based on whether that row is the hovered one.
How to build it in React
Set up motion values and hovered index
Create mouseX and mouseY with useMotionValue(0) and a hoveredIndex state initialized to null. On the row container div, attach an onMouseMove handler that reads the pointer position relative to the container and writes it to both motion values.
const mouseX = useMotionValue(0); const mouseY = useMotionValue(0); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const handleMouseMove = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); mouseX.set(e.clientX - rect.left + 24); mouseY.set(e.clientY - rect.top - 160); };Build the article row with hover state
Each row is a motion.a. On mouseEnter write the row index into hoveredIndex, on mouseLeave reset to null. Inside the row, wrap the title in a motion.h3 and animate its x between 0 and 12 based on whether this index matches hoveredIndex. Do the same for the ArrowUpRight icon rotation.
<motion.h3 animate={{ x: hoveredIndex === i ? 12 : 0 }} transition={{ duration: 0.3, ease: EASE }} > {article.title} </motion.h3>Mount the floating preview card with AnimatePresence
Inside the relative container, after the rows, add an AnimatePresence block. When hoveredIndex is not null, render a motion.div positioned absolute with style left and top bound to mouseX and mouseY. Give it an initial scale of 0.92 and opacity 0, animate to scale 1 and opacity 1, and set pointerEvents to none so it never interferes with the rows below.
<AnimatePresence> {hoveredIndex !== null && ( <motion.div initial={{ opacity: 0, scale: 0.92 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.92 }} transition={{ duration: 0.2 }} style={{ position: "absolute", left: mouseX, top: mouseY, pointerEvents: "none", zIndex: 40, }} /> )} </AnimatePresence>Populate the card with image and excerpt
Inside the motion.div, render a fixed-height div with backgroundImage set to the hovered article's image URL, followed by a padding block showing the excerpt and the date. Keep the card width around 280px and add a box-shadow for depth. The card will always reflect the currently hovered article because it reads from articles[hoveredIndex].
When to use it
Use this pattern on agency, portfolio, or SaaS blog sections where you want the post list to feel editorial and premium. It works best with 4 to 8 articles, each with a compelling image. Skip it when posts lack quality images, when users are on mobile-first audiences (hover does not fire on touch), or when the list is long enough that a grid or card layout provides better scannability.
Used by
- Stripe, Uses a minimal stacked article list with typographic hierarchy and subtle row interactions on its engineering blog.
- Vercel, Clean article listing with hover highlights and rich preview metadata appearing on row interaction.
- Linear, Employs row-based article lists with hover state transitions that surface additional context near the pointer.
FAQ
Does the hover preview work on mobile?
No. Touch screens do not fire hover or mousemove events, so the floating card never appears. The article list itself is fully functional on mobile; only the decorative preview is absent.
Why use useMotionValue instead of useState for the cursor position?
useMotionValue updates the DOM style directly without triggering a React re-render on every mousemove event. With useState, a fast-moving cursor would queue dozens of renders per second and make the card stutter; useMotionValue keeps the card locked to the cursor at 60fps.
How do I prevent the card from overflowing the viewport edge?
Add a clamp inside handleMouseMove: cap mouseX so that x + cardWidth never exceeds the container width, and cap mouseY symmetrically. A simple Math.min check on both axes is enough for most cases.
Can I replace the image with a video preview?
Yes. Swap the background-image div for a muted autoplay video element. Pair it with a key prop equal to the article URL so React unmounts and remounts the video element each time a new row is hovered, restarting playback from the beginning.