How to build a stacked scroll portfolio in React
A stacked scroll portfolio in React uses position:sticky with incremental top offsets per card so each card pins at a different depth as the user scrolls. Framer Motion's useScroll and useTransform animate scale from 0.88 to 1 and opacity from 0.4 to 1 as each card enters the viewport, creating a layered deck effect.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~190 lines, zero extra dependencies.
- Core APIs: useScroll (target + offset), useTransform, position:sticky per card.
- Accepts 3 to 5 projects; beyond 5 the stack depth grows awkward on short screens.
- Accessible: cards are standard article-like markup; the animation is decorative and does not hide content.
- Responsive: the 2-column grid inside each card collapses naturally on narrow viewports.
Portfolio Stacked Scroll is a React section that presents project cards as a layered deck: each card pins with position:sticky at a progressively deeper offset, so scrolling through the list feels like lifting cards from a stack. The Framer Motion scale and opacity transforms give every card a satisfying reveal as it comes into view. For agencies and freelancers who want to show work without a grid, this pattern communicates craft without requiring a custom CMS.
Anatomy
The outer section contains a centred container with a header block (overline label + H2 title) and a card list below. Each card is a StackedCard component wrapping a motion.div inside a sticky div. The sticky div holds the top offset (80px base plus 40px per card index) and a bottom margin of 20vh between cards except the last. Inside the motion.div, a two-column CSS grid lays out a 16:10 colour-block placeholder on the left and the project metadata (category label, title, description, CTA link) on the right.
How it works
Each StackedCard holds a ref attached to its outer sticky div. Framer Motion's useScroll reads scrollYProgress for that element with offset ['start end', 'start start'], meaning 0 when the card bottom touches the viewport bottom and 1 when the card top aligns with the viewport top. useTransform maps that progress to scale (0.88 to 1) and opacity (0.4 at 0, 0.8 at 0.5, 1 at 1). The whileInView y-transition (60px to 0, once:true) handles the initial entrance animation independently of the scroll progress. The sticky top offset increases by 40px per card index, creating the visual depth of the deck without JavaScript layout calculations.
How to build it in React
Set up each card as a sticky container
Render each card inside a div with position:sticky and a computed top value. The base offset is 80px and each subsequent card adds 40px, so cards visually layer behind each other. Set marginBottom to 20vh for all cards except the last so there is enough scroll distance for the animation to play.
style={{ position: "sticky", top: `calc(80px + ${index * 40}px)`, marginBottom: index < total - 1 ? "20vh" : 0, zIndex: index, }}Track scroll progress per card with useScroll
Attach a ref to the sticky container and pass it as the target of useScroll. The offset option controls when scrollYProgress starts and ends: 'start end' fires when the card bottom enters the bottom of the viewport, and 'start start' fires when the card top reaches the top. This gives a full 0-to-1 range per card.
const ref = useRef<HTMLDivElement>(null); const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "start start"], });Map scroll progress to scale and opacity
Use useTransform to derive a scale value (0.88 when the card first appears, 1 when fully in view) and an opacity value with a three-keyframe curve so the card fades up smoothly. Apply both to the motion.div wrapping the card content.
const scale = useTransform(scrollYProgress, [0, 1], [0.88, 1]); const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0.4, 0.8, 1]); <motion.div style={{ scale, opacity }} initial={{ y: 60 }} whileInView={{ y: 0 }} viewport={{ once: true }} transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }} >Build the card layout with a 2-column grid
Inside the motion.div, use a CSS grid with columns 1.2fr 1fr and a 2.5rem gap. The left column holds the project image placeholder (16:10 aspect ratio, background colour from data), the right column holds the category label, project title, description paragraph, and an anchor CTA. All colours reference CSS custom properties from the theme tokens so the component works across all 7 presets.
When to use it
This pattern fits agency portfolios, freelance case-study pages, and product feature showcases where showing 3 to 5 items sequentially creates narrative momentum. The sticky layering rewards careful scrolling and signals premium execution. Avoid it when you have more than 5 items (the stack gets too deep) or when users need to compare projects side by side. On mobile the sticky stack works but the 20vh margins take up significant screen real estate, so test on an actual device before shipping.
Used by
- Stripe, Uses stacked sticky sections in its product pages to walk through sequential feature reveals as the user scrolls.
- Lottiefiles, Employs scroll-driven card stacking to present integration steps with depth and motion on its landing pages.
- Pitch, Stacks feature cards that pin and scale into view during scroll sections on its marketing site.
FAQ
Why does each card need its own useScroll ref instead of one global scroll listener?
Framer Motion's useScroll with a target ref tracks the scroll progress relative to that specific element, so each card gets its own 0-to-1 range tied to when it enters and fills the viewport. A single global listener would give all cards the same number and make independent per-card animations impossible.
How do I replace the colour block placeholder with a real image?
Swap the inner div for a Next.js Image (or a plain img) with objectFit:cover inside the same 16:10 aspect-ratio container. Keep overflow:hidden on the wrapper so rounded corners clip the image correctly. Pass the image src through the Project interface alongside the existing color field.
The cards overlap each other visually. How do I control the stacking order?
The zIndex is set to the card's index value (0, 1, 2…) so later cards sit on top of earlier ones. If you want earlier cards to appear on top (a reverse deck), set zIndex to total - index instead.
Does the sticky scroll stack work inside a modal or an overflow:hidden parent?
No. position:sticky stops working when any ancestor has overflow:hidden or overflow:auto set. Make sure the scroll container that drives the effect is the document body or a container with overflow:visible all the way up the tree.