40 Pet Images

Case Study: Scroll-Driven Video Layout via GSAP ScrollTrigger

The Problem: Smooth Frame-by-Frame Video Scrubbing

  • The Design: The client wanted an immersive full-screen header where a background video played only as the user scrolled down the page. Overlaid on top of the video was a text block (heading, paragraph, and a downward arrow) that needed to fade out and scroll away before the video finished playing. Once the video reached its final frame, the page would unlock so the user could browse the rest of the site.
  • The Snag: When I first hooked up the video to scroll natively, it stuttered and lagged heavily. I discovered that normal web videos aren't meant to be scrubbed through frame-by-frame. Because of how they are compressed, the browser has to do a massive amount of mental math on the fly just to guess what a random middle frame looks like, causing the whole page to stutter.
  • The Responsiveness Constraint: Serving a single, heavy desktop-sized video to a mobile phone would crush loading speeds and waste user data. I needed distinct video assets for mobile, tablet, and desktop viewports.

The Solution: Practical Encoding and Scripted Asset Swapping

  • Fixing the Video Codec: I ran the videos through a command-line tool called FFmpeg to re-encode them. By tweaking the settings, I forced the video format to store full, individual images for every single frame instead of relying on compression shortcuts. This completely removed the lag, letting the browser scrub forward and backward flawlessly. I generated three separate versions of the video for mobile, tablet, and desktop breakpoints.
  • Building a Dynamic Swapping Engine: I wrote a short JavaScript function that checks the user's screen width as soon as the page loads. It automatically matches the device to the right mobile, tablet, or desktop video file. I also made sure it listens for screen changes, so if a desktop user resizes their browser, the script instantly swaps out the source file to the correct version on the fly.
  • Connecting the GSAP Timelines: I used GSAP ScrollTrigger to link the layout directly to the user's scrollbar:
    • The Video: I pinned the header section to lock it in place while the animation plays out. I set a slight 0.5-second smoothing delay to take the edge off rough mouse-wheel clicks, and wrote a direct link that ties the user's exact scroll percentage straight to the video's current playback timeline.
    • The Text: I built a separate, simultaneous animation block for the foreground elements (headerContent), causing them to fade out and slide upward cleanly as the user scrolled.

The Result: High-Fidelity Interaction without the Lag

  • Smooth Performance: By re-encoding the video files and using GSAP's scroll smoothing, the header felt incredibly fluid, executing frame-by-frame seeking with no noticeable lag on desktop or mobile.
  • Efficient Asset Delivery: Mobile users received lightweight, smaller video files, while desktop users got the crisp high-resolution version, protecting both performance and visual quality.
  • Reliable Clean-Up: Integrated simple lifecycle hooks (like onLeaveBack) to ensure that if a user scrolled back to the top of the page, the animation reset cleanly to zero without getting stuck.

Under the Hood: The Scroll-Driven Setup

How It Works

To sync video frame playback perfectly with manual scrolling without killing browser performance, the application decouples heavy media rendering from structural viewport resizing. Rather than serving a massive, one-size-fits-all file, a custom asset switcher delivers responsive media files before a twin-track GSAP timeline links page position directly to video progression.

The Structural Markup (HTML)

Instead of cluttering the HTML or writing complex JavaScript layout maps, the structure relies on native data attributes on a single, silent video element.

  • Responsive Data Mapping: The script automatically pulls custom breakpoints directly out of the video element's data-mobile-src, data-tablet-src, and data-desktop-src tags. This isolates content paths within the HTML, keeping configuration completely separate from the core engine code.
  • Mobile and Accessibility Safety: The video element explicitly relies on playsinline and muted attributes. This ensures that mobile browsers allow the video to load and scrub inline without forcefully spinning it out into an annoying, device-native full-screen media player.
<header class="video-header">
  <!-- Target video element storing responsive asset paths directly in data attributes -->
  <div class="video-container">
    <video id="headerVideo" playsinline muted preload="auto"
      data-mobile-src="images/hero_mobile.mp4"
      data-tablet-src="images/hero_tablet.mp4"
      data-desktop-src="images/hero_desktop.mp4">
    </video>
  </div>

  <!-- Foreground copy layered smoothly above the pinned video canvas -->
  <div id="hero">
    <div class="heading">
      <h1>Follow<br> <span>Orange Cream</span>'s <br>Path to Healing</h1>
      <p>Orange Cream is just one of countless homeless animals who learned what it means to be saved by Organization.  Follow along and learn how.</p>
    </div>
  </div>
</header>

The Layout Logic & Scroll Synchronization (JavaScript)

  • Asynchronous Duration Insurance: Browsers don't always know a video's runtime length the second a script fires. If video.duration reads as NaN, standard scroll scripts completely break. To resolve this, the engine uses a smart recursive safety loop: if duration details aren't ready, it automatically wait-checks again after 100 milliseconds rather than letting the initialization fail.
  • Performance-Safe Debounced Resizing: Swapping video files out on the fly during an active browser resize event can trigger massive layout stutters. To protect user hardware, the engine wraps window listening within a custom debounce buffer. This groups rapid screen scaling into a single execution pass, waiting 250 milliseconds after the user finishes resizing before recalculating.
  • Dual-Track Timeline Mapping: Rather than trying to control text overlays and video frames within a single overwhelming timeline, the system separates them into distinct scroll targets:
    • The Frame-Seeking Loop: One master ScrollTrigger locks the section in place via layout pinning. As a user scrolls, the onUpdate progress calculation updates video.currentTime directly against the total duration duration timeline.
    • The Foreground Fade: A completely separate GSAP animation independently translates the text block upward (yPercent: -100) and drops its opacity to zero on a rapid, natural curve long before the video reaches its final frame.
  • Reliable Memory Cleanup: To prevent invisible scroll listeners or frozen animations from lagging other areas of the portfolio site, global lifecycle hooks run on window unloads, systematically killing and purging all registered ScrollTriggers from memory.
/**
 * Responsive Video Scroll-Sync Engine
 * Handles asset loading, debounced scaling adjustments, and independent GSAP triggers.
 */
document.addEventListener("DOMContentLoaded", () => {
  gsap.registerPlugin(ScrollTrigger);

  const video = document.getElementById("headerVideo");
  const header = document.querySelector(".video-header");
  const headerContent = document.querySelector(".heading");

  const breakpoints = {
    mobile: '(max-width: 767px)',
    tablet: '(min-width: 768px) and (max-width: 1023px)',
    desktop: '(min-width: 1024px)'
  };

  const videoSources = {
    mobile: video.dataset.mobileSrc,
    tablet: video.dataset.tabletSrc,
    desktop: video.dataset.desktopSrc
  };

  let currentVideoSrc = '';

  // Swaps video file src on breakpoint changes and reloads media context
  function setVideoSource() {
    let newSrc = '';
    if (window.matchMedia(breakpoints.mobile).matches) {
      newSrc = videoSources.mobile;
    } else if (window.matchMedia(breakpoints.tablet).matches) {
      newSrc = videoSources.tablet;
    } else {
      newSrc = videoSources.desktop;
    }

    if (newSrc && newSrc !== currentVideoSrc) {
      video.src = newSrc;
      video.load();
      currentVideoSrc = newSrc;
      return true;
    }
    return false;
  }

  // Groups screen-resize execution loops to prevent layout stuttering
  function debounce(func, delay) {
    let timeout;
    return function(...args) {
      const context = this;
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(context, args), delay);
    };
  }

  // Orchestrates individual ScrollTrigger mapping paths
  function setupAllScrollTriggers() {
    ScrollTrigger.getAll().forEach(st => st.kill());
    gsap.set(headerContent, { clearProps: "transform,opacity" });

    // Recursive safety check: yields initialization if browser duration isn't calculated yet
    if (!isNaN(video.duration)) {

      // Track 1: Dynamic timeline linking frame location to current scroll bar depth
      video.scrollScrubTrigger = ScrollTrigger.create({
        trigger: header,
        start: "top top",
        end: () => `+=${header.offsetHeight}`,
        scrub: 0.5, // Smooths rough mouse clicks
        pin: true,
        onUpdate: self => { video.currentTime = video.duration * self.progress; },
        onLeaveBack: () => { video.currentTime = 0; },
      });

      // Track 2: Decoupled visual fade out for typography elements
      headerContent.contentScrollTrigger = gsap.to(headerContent, {
        yPercent: -100,
        opacity: 0,
        ease: "power1.out",
        scrollTrigger: {
          trigger: header,
          start: "top top+=100",
          end: "bottom top",
          scrub: true,
        }
      });
    } else {
      // Re-try flag loop if media runtime is still evaluating
      setTimeout(setupAllScrollTriggers, 100);
    }
    ScrollTrigger.refresh();
  }

  // Clean lifecycle memory flush on page transitions
  window.addEventListener('beforeunload', () => {
    ScrollTrigger.getAll().forEach(st => st.kill());
  });
});