SectionsIntermediateApril 27, 2026
Case Overview Scroll
Full-viewport stacked case study slider. Each slide is sticky and its image receives scroll-driven translateY and scale transforms, creating a parallax depth effect as slides layer over each other. Inspired by DashDigital's case overview pattern.
View Full Demo →Preview
Source
demo.jsx
import CaseOverviewScroll from "./index.jsx";
const slides = [
{
title: "Novatrex",
tags: ["strategy", "design", "development"],
imageSrc: "/demo-assets/Novatrext/component1.webp",
href: "#",
},
{
title: "Kick & Bass",
tags: ["branding", "design", "development"],
imageSrc: "/demo-assets/kickandbass.png",
href: "#",
},
{
title: "Westend",
tags: ["research", "strategy", "design"],
imageSrc: "/demo-assets/westend.png",
href: "#",
},
{
title: "Delivrd",
tags: ["design", "development", "content"],
imageSrc: "/demo-assets/delivrd.png",
href: "#",
},
{
title: "Social Stats",
tags: ["research", "strategy", "design", "development"],
imageSrc: "/demo-assets/socialstats.png",
href: "#",
},
{
title: "Studio Portfolio",
tags: ["design", "development"],
imageSrc: "/demo-assets/Novatrext/component3.webp",
href: "#",
},
];
export default function CaseOverviewScrollDemo() {
return <CaseOverviewScroll slides={slides} />;
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import styles from "./styles.module.css";
gsap.registerPlugin(ScrollTrigger);
export default function CaseOverviewScroll({ slides = [] }) {
const sectionRef = useRef(null);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const images = el.querySelectorAll(`.${styles.slideImage}`);
const total = images.length;
if (total < 2) return;
// Set initial transforms
images.forEach((img, i) => {
gsap.set(img, {
yPercent: i * 10,
scale: 1 - i * 0.02,
force3D: true,
});
});
const ctx = gsap.context(() => {
ScrollTrigger.create({
trigger: el,
start: "top top",
end: "bottom bottom",
scrub: 0.6,
onUpdate: (self) => {
const current = self.progress * (total - 1);
images.forEach((img, i) => {
const offset = i - current;
gsap.set(img, {
yPercent: offset * 10,
scale: 1 - offset * 0.02,
force3D: true,
});
});
},
});
}, el);
return () => ctx.revert();
}, [slides.length]);
return (
<section ref={sectionRef} className={styles.section}>
<div className={styles.container}>
{slides.map((slide, i) => {
const Tag = slide.href ? "a" : "div";
const linkProps = slide.href
? { href: slide.href, target: slide.external ? "_blank" : undefined }
: {};
return (
<Tag key={i} className={styles.slide} {...linkProps}>
<div className={styles.content}>
<div className={styles.headingWrap}>
<h3 className={styles.title}>{slide.title}</h3>
{slide.tags && (
<div className={styles.tags}>
{slide.tags.map((tag) => (
<div key={tag}>{tag}</div>
))}
</div>
)}
</div>
</div>
<img
className={styles.slideImage}
src={slide.imageSrc}
alt={slide.alt || ""}
loading={i === 0 ? "eager" : "lazy"}
decoding="async"
/>
</Tag>
);
})}
</div>
</section>
);
}
styles.module.css
.section {
position: relative;
background: #0a0a0a;
color: #fafafa;
}
/* ── Slide ───────────────────────────────────────────────────── */
.slide {
position: sticky;
top: 0;
display: block;
height: 100vh;
width: 100%;
overflow: hidden;
text-decoration: none;
color: inherit;
cursor: pointer;
}
/* ── Image ───────────────────────────────────────────────────── */
.slideImage {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
will-change: transform;
}
/* ── Content overlay ─────────────────────────────────────────── */
.content {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: flex-end;
padding: 40px 48px;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.55) 0%,
rgba(0, 0, 0, 0) 40%
);
}
@media (max-width: 768px) {
.content {
padding: 24px 20px;
}
}
.headingWrap {
display: flex;
align-items: baseline;
gap: 20px;
flex-wrap: wrap;
}
.title {
font-size: clamp(1.5rem, 3vw, 2.25rem);
font-weight: 400;
line-height: 1.2;
letter-spacing: -0.01em;
}
.tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
font-size: 0.75rem;
letter-spacing: 0.02em;
opacity: 0.6;
}
@media (max-width: 768px) {
.headingWrap {
flex-direction: column;
gap: 8px;
}
}
Dependencies
gsap





