How to Build a Dynamic Scroll-Driven Video Background

Turn an AI-generated video into a background that transforms as visitors scroll through your page. The background isn't decorative; it's narrative.

🎬 Prefer a video? Watch the full narrated walkthrough below, or scroll down for the step-by-step written tutorial.

✍️ Note from the Creator

Most websites treat backgrounds as static wallpaper. An image behind the text, maybe a parallax effect if someone's feeling ambitious. That's fine for a portfolio site. But if you're building something immersive, a world someone should feel they're entering, you need the environment to respond to the visitor.

This tutorial covers the technique I used for The Roar of Winchester. When a visitor scrolls through the site, the background isn't static. In the "Collapse" variant, a command center explodes and disintegrates as you read. In the "Rebirth" variant, a sun rises over a city at dawn. The background becomes part of the storytelling.

The core innovation here is the video loop background, which is the technique of converting an AI-generated video into a sequence of frames and mapping them to scroll position. The rest (CSS, page layout, overlay opacity) is standard web development. I'll include it for completeness, but the video-to-scroll pipeline is the part worth paying attention to.

Prerequisites

Why not just use a <video> tag? A looping <video autoplay muted loop> plays at its own pace regardless of user behavior. The scroll-driven approach gives you precise control: the video progresses exactly in sync with how far the user has scrolled. Frame 1 at the top, frame 192 at the bottom. The user controls the narrative pace.

What You'll Build

Here it is in action. Watch how the background transforms from an intact command center to full destruction as the page scrolls:

The Collapse variant: scroll position drives the background destruction sequence

The technique is simple in concept: a full-page background that transitions through a video sequence as the user scrolls. At the top of the page, the background shows the first frame. As the user scrolls to the bottom, it smoothly transitions through all frames to the final state.

Frame 1 - Start
Frame 1 (top of page)
Frame 96 - Middle
Frame 96 (mid-scroll)
Frame 192 - End
Frame 192 (bottom)

This is from the "Collapse" variant. Frame 1 shows a futuristic command center intact. By frame 192, it has been destroyed. The visitor experiences the destruction by scrolling through the page content.

1 Generate the Video Loop

The process starts with generating a short video (5-10 seconds) using an AI video generation tool. The video should show a continuous transformation: something building, something destroying, a time-of-day change, weather shifting, or any gradual visual transition.

Example Prompt (Google Veo 3) A futuristic command center with holographic displays and control panels. The room begins intact and fully operational. Over the course of the video, an explosion rips through the center of the room, shattering glass, sending debris flying, and engulfing equipment in fire. Camera remains fixed. Cinematic lighting, photorealistic, 8K detail.
Key insight: Keep the camera fixed. You want the environment to change while the camera stays locked in position. Any camera movement will create jarring jumps when mapped to scroll position. A slow zoom is acceptable; panning or orbiting is not.

Aim for 5-8 seconds of video. Longer videos produce more frames and smoother scroll transitions, but increase total file size. Our production implementation uses 192 frames extracted from an 8-second Veo 3 generation.

Video provider note: Different providers handle fixed-camera prompts with varying reliability. Veo 3 consistently respects "camera remains fixed" instructions. Some providers may add unwanted camera drift. If your output has camera movement, regenerate or use a different provider.

2 Extract Frames with ffmpeg

Once you have your video, extract individual frames as JPEG images. This is the bridge between a video file and scroll-driven control.

Terminal
# Create output directory
mkdir -p temporal-frames

# Extract all frames as sequentially numbered JPEGs
ffmpeg -i collapse-video.mp4 -q:v 3 temporal-frames/frame_%04d.jpg

The -q:v 3 flag controls JPEG quality (2-5 is the sweet spot; lower = higher quality = larger files). The %04d pattern produces frame_0001.jpg, frame_0002.jpg, etc.

Optimize the frame count

A 24fps video produces 24 frames per second. For an 8-second video, that's 192 frames. This works well. If your video is longer, you can reduce the frame rate:

Terminal
# Extract at 12fps instead of native framerate (halves frame count)
ffmpeg -i your-video.mp4 -vf "fps=12" -q:v 3 temporal-frames/frame_%04d.jpg

Optimize file size

Each frame should be as small as possible without visible degradation. For a full-screen background, 1280px wide is sufficient. Resize during extraction:

Terminal
# Extract, resize to 1280px wide, and compress
ffmpeg -i your-video.mp4 -vf "scale=1280:-1" -q:v 4 temporal-frames/frame_%04d.jpg
Size target: Aim for 50-100KB per frame. At 192 frames, that's 10-20MB total. Our production "Collapse" set is 17MB for 192 frames (average 89KB each). The "Rebirth" set is 35MB (higher detail dawn scene). Both load quickly with the progressive loading strategy covered in Step 3.

3 The Scroll Engine (The Innovative Part)

This is the core of the technique. The scroll engine maps the user's scroll position to a frame index, then swaps the background image. It uses requestAnimationFrame for smooth performance and a two-phase loading strategy so the page doesn't wait for all 192 frames before displaying anything.

scroll-engine.js
(function() {
  var bg = document.getElementById('temporal-bg');
  var totalFrames = 192;
  var frames = new Array(totalFrames);
  var ready = false;
  var lastIdx = -1;
  var ticking = false;

  // Generate frame file paths
  function pad(n) { return String(n).padStart(4, '0'); }
  function src(i) { return 'temporal-frames/frame_' + pad(i) + '.jpg'; }

  // ==========================================
  // PHASE 1: Load first 10 frames immediately
  // ==========================================
  // This gets the background visible in under 1 second
  // while the remaining 182 frames load in the background.
  var phase1 = 10;
  var p1loaded = 0;

  for (var i = 1; i <= phase1; i++) {
    (function(idx) {
      var img = new Image();
      img.onload = function() {
        frames[idx - 1] = img;
        p1loaded++;
        if (p1loaded === phase1) {
          // First 10 frames loaded - activate the background
          bg.src = frames[0].src;
          bg.style.display = 'block';
          document.body.classList.add('temporal-active');
          ready = true;
          loadRemaining(); // Start loading the rest
        }
      };
      img.src = src(idx);
    })(i);
  }

  // ==========================================
  // PHASE 2: Load remaining frames in batches
  // ==========================================
  // Loads 20 frames at a time with 50ms gaps to
  // avoid blocking the main thread.
  function loadRemaining() {
    var batch = 20;
    var cursor = phase1 + 1;
    function loadBatch() {
      var end = Math.min(cursor + batch, totalFrames + 1);
      for (var i = cursor; i < end; i++) {
        (function(idx) {
          var img = new Image();
          img.onload = function() { frames[idx - 1] = img; };
          img.src = src(idx);
        })(i);
      }
      cursor = end;
      if (cursor <= totalFrames) {
        setTimeout(loadBatch, 50);
      }
    }
    loadBatch();
  }

  // ==========================================
  // SCROLL HANDLER with rAF throttle
  // ==========================================
  // Maps scroll percentage to frame index.
  // requestAnimationFrame prevents layout thrashing.
  window.addEventListener('scroll', function() {
    if (!ready || ticking) return;
    ticking = true;
    requestAnimationFrame(function() {
      var scrollMax = document.body.scrollHeight - window.innerHeight;
      if (scrollMax > 0) {
        var pct = Math.min(1, Math.max(0, window.scrollY / scrollMax));
        var idx = Math.min(totalFrames - 1, Math.floor(pct * (totalFrames - 1)));
        if (idx !== lastIdx && frames[idx]) {
          bg.src = frames[idx].src;
          lastIdx = idx;
        }
      }
      ticking = false;
    });
  }, { passive: true });
})();

How it works

  1. Phase 1 (instant): Loads frames 1-10 immediately. The background activates as soon as these are ready (typically under 1 second).
  2. Phase 2 (background): Loads frames 11-192 in batches of 20 with 50ms pauses between batches. This prevents the image loading from blocking user interaction.
  3. Scroll mapping: On each scroll event, calculates what percentage of the page has been scrolled (0.0 to 1.0) and maps that to a frame index (0 to 191). Only updates the <img> src when the frame index actually changes.
  4. rAF throttle: The requestAnimationFrame wrapper ensures we only update once per display refresh (60fps max), preventing performance degradation from rapid scroll events.

4 Page Structure (Standard CSS)

The HTML structure is straightforward. The key elements are a fixed-position <img> for the background and a semi-transparent overlay so text remains readable. This is standard CSS, nothing novel here.

HTML
<!-- The scroll-driven background image -->
<img id="temporal-bg" src="" alt=""
     style="position:fixed; inset:0; width:100%; height:100%;
            object-fit:cover; z-index:-2; display:none;">

<!-- Semi-transparent overlay for text readability -->
<div style="position:fixed; inset:0; z-index:-1;
            background:rgba(5, 6, 10, 0.75);"></div>

<!-- Your page content goes here -->
<div class="content">
  <h1>Your Page Title</h1>
  <p>As the visitor scrolls through this content,
     the background transitions through the video frames.</p>
  <!-- Add enough content to create scroll depth -->
</div>
Overlay opacity: Adjust the rgba(5, 6, 10, 0.75) value based on your content. More text-heavy pages need higher opacity (0.75-0.85). Image-focused pages can go lower (0.55-0.65). Our "Rebirth" variant uses 0.55 to let the dawn colors show through; "Collapse" uses 0.78 for readability over the bright explosion frames.
CSS
/* Hide the default body background when temporal bg is active */
body.temporal-active::before {
  display: none !important;
}

/* Optional: style adjustments per theme */
body.theme-rebirth .overlay {
  background: rgba(5, 6, 10, 0.55); /* lighter for dawn scene */
}

/* Standard scroll-triggered fade-in for content sections */
.fade-in {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s, transform 0.6s;
}
.fade-in.visible {
  opacity: 1;
  transform: translateY(0);
}

The .fade-in class paired with an IntersectionObserver is a common pattern for revealing content sections as the user scrolls. It works nicely with the dynamic background since both respond to scroll position, creating a cohesive experience.

5 Performance Optimization

The two-phase loading strategy handles most performance concerns, but here are additional optimizations for production deployment:

Frame file size

CDN and caching

Mobile considerations

Don't use CSS background-image. Swapping background-image on a fixed element triggers expensive repaints. Using an <img> element and changing its src attribute is significantly faster because the browser can optimize image decoding separately from layout.

6 Deploy

Upload your frame directory alongside your HTML, CSS, and JavaScript. The file structure should look like this:

File Structure
your-site/
  index.html
  scroll-engine.js
  temporal-frames/
    frame_0001.jpg
    frame_0002.jpg
    ...
    frame_0192.jpg

That's it. No build tools, no bundler, no framework dependencies. The scroll engine is 60 lines of vanilla JavaScript. It works in every modern browser.

⊞ How It All Fits Together

The complete flow:

  1. Generate: AI video tool creates a 5-8 second transformation video with a fixed camera
  2. Extract: ffmpeg splits the video into 100-200 individual JPEG frames
  3. Load: JavaScript loads the first 10 frames immediately, remaining frames in background batches
  4. Map: Scroll position (0%-100%) maps to frame index (1-192)
  5. Swap: requestAnimationFrame updates the background image src on each frame change
  6. Experience: Visitor scrolls through your content while the world transforms behind them

The result is a website that feels alive. The background isn't decoration; it's synchronized to the visitor's journey through your content. Every scroll is a step further into the narrative.

? Troubleshooting

Background flickers or shows white gaps

This happens when the scroll moves faster than frames can load (Phase 2 hasn't finished). The frames[idx] check in the scroll handler prevents displaying unloaded frames. Increase Phase 1 to 20-30 frames if your page has minimal content above the fold.

Performance drops on scroll

Ensure you're using { passive: true } on the scroll listener. Without it, the browser waits for the handler to complete before scrolling, creating visible jank. Also verify the ticking flag is working. It should prevent multiple rAF calls from stacking.

Frames load too slowly

Reduce frame file size (increase JPEG compression), reduce frame dimensions, or reduce total frame count. If targeting global audiences, use a CDN. Our production frames average 89KB each and load all 192 within 3-4 seconds on a typical connection.

Camera movement in the video causes jumps

Regenerate the video with explicit "camera remains fixed" instructions. If the provider doesn't support fixed-camera well, try a different tool or use post-processing to stabilize the footage before frame extraction.

Built for The Roar of Winchester. See it in action: Collapse variant | Rebirth variant.