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 →
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;
  }
}
  • gsap

May 11, 2026

SECTIONS

Dual Push Cards

Two-up CTA card section with scroll-driven parallax on each card's background image. Cards scale down and drift vertically as you scroll past. Glassmorphic blur buttons at bottom-left. Stacks on mobile, side-by-side grid on desktop.

May 4, 2026

SECTIONS

Portfolio Grid

A responsive portfolio showcase section with a header tagline, blinking cursor counters, a 2-up/4-col project card grid with hover-zoom images and data-label metadata, plus a full-width CTA button. Scroll-triggered fade and move-up animations via GSAP.

May 1, 2026

SECTIONS

Logo Wall Cycle

A responsive logo grid that cycles through brand logos with smooth GSAP-powered swap animations. Shows 8 logos on desktop and 6 on tablet, shuffling hidden logos into view on a timed loop. Pauses when out of viewport or tab is hidden.