How to build a sticky feature panel that syncs with scroll in React
A scroll-synced sticky feature panel in React tracks the viewport midpoint on every scroll event, determines the closest feature item, and swaps the right-side visual using AnimatePresence. The active item is highlighted with a left border accent and a faint background tint.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~160 lines split across two files.
- Scroll detection uses useMotionValueEvent on scrollY, no scroll event listeners to clean up manually.
- Visual swap: AnimatePresence mode='wait' with opacity/scale/y transitions at 350ms.
- Responsive: single-column stacked layout below 768px via an inline media query.
- Accessible: feature items are clickable, and the active state is driven by scroll position, not hover.
Features Spotlight Pin is a two-column section inspired by Linear's feature pages. The left column lists your features as cards; the right column stays pinned to the viewport and renders a matching visual that fades and scales in as the user scrolls past each item. The result turns a plain feature list into a product walkthrough without any JavaScript frameworks beyond what you already ship.
Anatomy
The outer container is a CSS grid with two equal columns. The left column is a flex column of feature cards, each containing an icon box (44x44px, rounded), an optional eyebrow label, a title, and a description. The active card gains a 3px left border in the accent color and a subtle background tint via color-mix. The right column holds a single sticky div (top: 6rem, height: 440px) that wraps the AnimatePresence switcher and the FeatureVisual sub-component.
How it works
On every scroll tick, useMotionValueEvent fires with the updated scrollY value. The handler iterates over the itemRefs array, calls getBoundingClientRect on each, and computes the absolute distance between each card's vertical center and 45% of the viewport height. The card with the smallest distance becomes the new activeIndex. Changing activeIndex re-keys the AnimatePresence child, triggering the exit animation on the outgoing visual (opacity 0, scale 0.97, y -6) and the enter animation on the incoming one (opacity 1, scale 1, y 0). Clicking a card sets activeIndex directly, bypassing the scroll detection.
How to build it in React
Set up the two-column grid and sticky panel
Create a CSS grid container with gridTemplateColumns: '1fr 1fr'. Give the right div position: sticky and a fixed top offset so it stays in view as the left column scrolls. Set an explicit height so the visual has a predictable bounding box.
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "3rem" }}> <div>{/* scrollable feature list */}</div> <div style={{ position: "sticky", top: "6rem", height: 440 }}> {/* sticky visual */} </div> </div>Track which item is closest to the viewport center
Store a ref array for each feature card. On every scrollY change, loop over the refs, compute the distance from each card's midpoint to 45% of window.innerHeight, and update activeIndex with the closest one. Using 45% (slightly above center) makes the switch feel snappy before the card fully passes mid-screen.
const { scrollY } = useScroll(); useMotionValueEvent(scrollY, "change", () => { const viewportMid = window.innerHeight * 0.45; let closest = 0, closestDist = Infinity; itemRefs.current.forEach((el, i) => { if (!el) return; const rect = el.getBoundingClientRect(); const dist = Math.abs(rect.top + rect.height / 2 - viewportMid); if (dist < closestDist) { closestDist = dist; closest = i; } }); setActiveIndex(closest); });Swap the visual with AnimatePresence
Wrap the right-column visual in AnimatePresence with mode='wait'. Key the inner motion.div on activeIndex so React unmounts the old one before mounting the new one. Define initial, animate, and exit props for the scale+fade transition.
<AnimatePresence mode="wait"> <motion.div key={activeIndex} initial={{ opacity: 0, scale: 0.95, y: 10 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, y: -6 }} transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }} > <FeatureVisual feature={features[activeIndex]} /> </motion.div> </AnimatePresence>Highlight the active feature card
On each card, compare its index to activeIndex and toggle a left border and background tint. Use color-mix so the tint stays on-theme regardless of the active preset. Lower the opacity of inactive cards to 0.45 to direct attention to the current one.
style={{ opacity: isActive ? 1 : 0.45, borderLeft: `3px solid ${isActive ? "var(--color-accent)" : "transparent"}`, background: isActive ? "color-mix(in srgb, var(--color-accent) 6%, var(--color-background-card))" : "transparent", }}
When to use it
Reach for this pattern when you have four to six distinct features you want to present as a guided narrative rather than a grid. It works well right after a hero section on SaaS product pages, where the goal is to walk prospects through capabilities one at a time. Skip it for three features or fewer (a simple grid reads faster) and avoid it on mobile-first products where sticky columns collapse into a plain stack that loses the reveal effect.
Used by
- Linear, Uses a scroll-pinned visual column on its features page to reveal each capability as the user reads down the left list.
- Vercel, Employs sticky right-side visuals that swap on scroll across several product marketing pages.
- Loom, Feature sections use a pinned preview panel that updates as the user scrolls through use-case descriptions.
- Notion, Product tour pages pin a screen mockup on the right while the left side walks through feature descriptions.
FAQ
Why use useMotionValueEvent instead of a plain scroll event listener?
useMotionValueEvent subscribes directly to Framer Motion's scrollY motion value, which is already throttled and runs outside the React render cycle. A plain addEventListener('scroll') fires on every scroll tick in the main thread and requires manual cleanup with useEffect; useMotionValueEvent handles both for you.
The sticky column doesn't work on mobile, why?
The component collapses to a single column below 768px. With one column, the visual appears inline after the feature list, there is no sticky behavior because sticky only makes sense alongside a scrollable sibling. This is intentional: a full-height sticky panel on a small screen would block almost all readable content.
Can I add more than five features without performance issues?
The scroll handler loops over all itemRefs on every scroll tick. For six to ten items the cost is negligible. Beyond that, consider throttling the handler with requestAnimationFrame or switching to an IntersectionObserver approach, which fires only when items cross the threshold instead of on every pixel of scroll.
How do I replace the gradient FeatureVisual with a real screenshot or video?
FeatureVisual is a separate sub-component that receives the feature object as a prop. Swap its internals for an <Image> or <video> element while keeping the same prop interface. AnimatePresence handles the transition regardless of what you render inside.