Search Small Tool Guides

Start typing to find a guide or tool.

Websites & Hosting

How to Build a Scroll-Controlled Video Website

Learn how to connect normalized scroll progress to seekable HTML video, build a sticky cinematic sequence, and test whether the result is visually convincing.

A scroll-controlled video does not play on a timer. The page turns document position into a media timestamp, making the reader’s scroll gesture the playback control.

We used this technique in the Cinematic Photography Website experiment. A camera separates into an exploded view, remounts, turns toward the viewer, and leads through its lens into a studio. The implementation is compact. Preparing media that makes every stage visually readable was the harder part.

The core mapping: scroll position → normalized progress → video.currentTime.

This guide explains the working architecture, the failure modes we observed, and the checks that prevented a technically active sequence from being mistaken for a static page.

What we are building

Three common video patterns behave differently:

Pattern What controls time? Typical behavior
Autoplay video The media clock Begins when browser policy and page conditions allow it
Background video The media clock Plays behind content, often looping and muted
Scroll-scrubbed video Document position Stays paused while code seeks to a timestamp

For scrubbing, the useful range is 0 to 1. Zero represents the beginning of a scene and one represents the end. Every position between them can be mapped to a time in seconds.

top of sequence     progress 0.00     video 0.00 s
middle              progress 0.50     video duration × 0.50
end of sequence     progress 1.00     video duration

The browser’s currentTime property reports and changes the playback position in seconds. Assigning a new value asks the browser to seek to that point in the available media.

Basic architecture

The smallest useful version needs five pieces:

  1. A seekable MP4.
  2. A tall outer section that creates scroll distance.
  3. A viewport-height sticky stage.
  4. JavaScript or TypeScript that normalizes position.
  5. A motion-reduced and media-failure path.

The outer section moves through the document. Its child stays fixed in the viewport until the section’s scroll distance is exhausted.

<section class="cinematic" data-cinematic>
  <div class="cinematic__sticky">
    <video muted playsinline preload="auto" data-video>
      <source src="/media/scene.mp4" type="video/mp4">
    </video>
  </div>
</section>

muted and playsinline are useful media attributes, but this pattern does not depend on autoplay. The element remains paused while the application changes its time.

Build the sticky scroll section

The sticky element is only the visible stage. The parent supplies the distance over which the timeline progresses.

.cinematic {
  position: relative;
  height: 500vh;
}

.cinematic__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

.cinematic video {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

If both elements are only 100vh, the sticky stage has almost no travel from which to calculate progress. If the parent is excessively tall, a meaningful transformation can be spread across so much wheel or touch movement that it feels inactive.

There is no universal height. Choose it while watching the real media. Our final cinematic sequence uses 560svh on desktop and 500svh on mobile because it contains several acts and two intentional holds.

We initially used object-fit: cover. In taller desktop viewports, it cropped the lateral edges where components were moving away from the camera. Switching the cinematic videos to contain preserved the motion needed to understand the exploded view. That is a composition decision, not a rule for every background video.

Normalize scroll progress

Calculate how far the outer section has traveled relative to its usable distance:

const clamp = (value, min = 0, max = 1) =>
  Math.min(max, Math.max(min, value));

const rect = section.getBoundingClientRect();
const distance = Math.max(1, section.offsetHeight - window.innerHeight);
const progress = clamp(-rect.top / distance);

When the section reaches the top, progress is approximately zero. When the bottom of its scrolling range is reached, progress is one. Clamping prevents values before or after the section from seeking outside the intended timeline.

An equivalent implementation can start from scrollY, a measured section start, and the same scrollable distance:

const progress = clamp(
  (window.scrollY - sectionStart) / scrollableDistance
);

Choose one coordinate system and test it after navigation, resize, and responsive layout changes.

Map progress to video time

For one continuous clip, the mapping is direct:

if (video.readyState >= 1 && Number.isFinite(video.duration)) {
  video.currentTime = progress * video.duration;
}

In the working experiment, we leave a small margin before the exact final timestamp and avoid assigning nearly identical values repeatedly:

const target = progress * Math.max(0, video.duration - 0.045);

if (Math.abs(video.currentTime - target) > 0.035) {
  video.currentTime = target;
}

These thresholds came from this media and test setup. They are implementation details to validate, not universal performance recommendations.

Divide a complex story into acts

One generated clip did not need to contain the entire story. The production experience maps ranges of global progress to three files:

Global progress Active act Local mapping
0.00–0.02 Assembled hold Disassembly at 0
0.02–0.26 Disassembly Forward 0 → 1
0.26–0.36 Exploded hold Disassembly at 1
0.36–0.56 Reassembly Disassembly backward 1 → 0
0.56–0.70 Front rotation Rotation forward 0 → 1
0.70–1.00 Lens and studio Portal forward 0 → 1

A helper converts one global interval into its own local zero-to-one range:

const range = (value, start, end) =>
  clamp((value - start) / (end - start));

The reassembly uses the accepted disassembly clip backward:

const reassembly = 1 - range(progress, 0.36, 0.56);
seek(disassemblyVideo, reassembly);

That made reassembly the exact temporal inverse of the approved separation. Generating a second approximation would have introduced another continuity boundary.

Schedule work with requestAnimationFrame

Scroll events can arrive many times between visible frames. We use them to request an update, then perform the read and writes in requestAnimationFrame, which schedules the callback before a repaint.

let scheduled = false;

const update = () => {
  scheduled = false;
  // Read geometry, calculate progress, select video, seek.
};

const requestUpdate = () => {
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(update);
};

window.addEventListener('scroll', requestUpdate, { passive: true });
window.addEventListener('resize', requestUpdate, { passive: true });

This consolidates multiple notifications into the next visual update. It does not guarantee smooth seeking: decoding cost, keyframe placement, device capability, media size, and browser behavior still matter.

Prepare seekable video

Scrubbing repeatedly asks the decoder for frames that may be far apart. A delivery encode optimized only for linear playback can respond poorly when the page seeks backward and forward.

The six published MP4 derivatives in our experiment are silent H.264 files with:

  • a fast-start container;
  • a six-frame GOP;
  • a minimum six-frame keyframe interval;
  • scene-cut keyframes disabled for predictable spacing;
  • 24 fps video;
  • yuv420p pixel format.

At 24 fps, six frames are approximately 0.25 seconds. The exact transcode used for the current desktop disassembly was:

ffmpeg -i source.mp4 -an \
  -vf scale=1600:902:flags=lanczos \
  -c:v libx264 -preset slow -crf 22 -profile:v high \
  -pix_fmt yuv420p -g 6 -keyint_min 6 -sc_threshold 0 \
  -movflags +faststart output-desktop.mp4

A shorter GOP can increase file size, so compare the seeking behavior and payload for your own footage rather than copying settings without measurement.

Create separate desktop and mobile derivatives

The current experience assigns media before calling load():

const mobile = matchMedia('(max-width: 720px)').matches;
video.src = mobile ? video.dataset.mobile : video.dataset.desktop;
video.load();

Desktop MP4s are 1600 × 902 and total 6,737,663 bytes. Mobile MP4s are 960 × 542 and total 2,571,629 bytes. Those are build artifacts measured in this project, not estimates of compressed network transfer or user-perceived speed.

During full-route local measurement, Interactive requested 7,135,851 first-party bytes on desktop and 2,773,139 bytes on mobile. Standard requested 350,930 and 174,452 bytes respectively. The cinematic version is substantially heavier even after responsive derivatives.

Respect reduced motion and handle failure

Large zooms and full-viewport movement need an alternative. Our default behavior checks the user’s system preference:

const reduceMotion = matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

In reduced mode, the sticky sequence is removed, a still-image story remains, and the MP4 files are not requested. The content after the opening remains available. The page also includes an explicit full-motion option for a reader who chooses it.

Media failure is a separate case. If a video cannot load, expose a useful static state and a route to the conventional version. Do not leave an indefinite loader covering the page.

Failure mode: the video works but looks static

This was the most useful failure in our experiment. Telemetry originally showed valid MP4 responses, decoded media, and changing currentTime. The experience still did not communicate the intended transformation strongly enough.

Four visual causes overlapped:

  1. The first disassembly source separated mainly front optical elements instead of the whole camera.
  2. object-fit: cover cropped lateral movement in tall viewports.
  3. The hero title and gradient remained over the product and obscured detail.
  4. The initial hold and scroll range diluted the visible change near the top.

We regenerated the source, used contain, faded the hero after 2.2% progress, reduced the shade, shortened the initial hold, and remapped the acts. The lesson is straightforward: a scroll-controlled experience cannot reveal motion that does not exist in the source media.

Test behavior and perception

A useful test plan has two layers.

Technical checks

  • Confirm each MP4 responds successfully and supports HTTP range requests where the server provides them.
  • Inspect currentSrc, duration, readyState, and currentTime.
  • Verify desktop and mobile choose their intended files.
  • Seek forward and backward through every act.
  • Simulate a failed media request.
  • Confirm reduced motion does not request cinematic MP4s.
  • Record console and page errors.

Visual checks

  • Capture frames at 0%, 10%, 20%, and every meaningful boundary.
  • Compare adjacent checkpoints without relying on stage labels.
  • Watch slow continuous scrolling in a real browser.
  • Check whether cropping hides movement at tall and narrow aspect ratios.
  • Confirm overlays disappear when the object needs full visual attention.
  • Ask whether an uninformed viewer can name the transformation.

Our automated suite covered seven widths from 360 to 1920 pixels, both scroll directions, decoded frames, reduced motion, fallback behavior, overflow, and errors. We still inspected the captured frames because assertions alone could not establish whether the motion was obvious.

See the live example

Read the complete Lab report and Standard-versus-Interactive measurements, or open the full-motion experimental demo. The demo uses generated media and is intentionally heavier than an ordinary service page.

For the production decisions behind the clips, continue to what we learned using AI video for an interactive website.

Limitations

  • Generated shots do not maintain physically perfect camera geometry.
  • Seeking behavior can vary by browser, decoder, device, and server delivery.
  • Mobile derivatives reduce payload but remain large compared with a still-image page.
  • Local build bytes are not field performance or Core Web Vitals.
  • We did not run a conversion, preference, engagement, or revenue study.
  • Automated browser coverage does not replace physical-device and assistive-technology testing.

The technique works when the footage, encoding, scroll mapping, and visual QA support one another. Treat the MP4 as part of the interface, not as decoration attached after the page is built.

Frequently Asked Questions

Does scroll-controlled video play automatically?

Not in this implementation. The video remains paused while scroll position selects its current time, so moving down or up the page moves through the source in either direction.

Why can a scrubbed video look static even when currentTime changes?

The source may contain too little visible motion, the scroll range may dilute the change, or cropping and overlays may hide the moving parts. Technical telemetry must be paired with visual checkpoints.

Sources and further reading

Primary and official sources used to verify factual guidance. Product features and policies can change; check the linked source before a consequential decision.