Video that starts when the reader reaches it and stops when they scroll past is one of the most requested viewport behaviours — and one of the easiest to ship in a broken state, because the failure modes are a rejected promise nobody logged, an accessibility preference nobody checked, and a battery cost nobody measured.
Concept Framing
The mental model that causes most of the trouble is "the observer starts the video". It does not. The observer produces a signal; whether playback happens is decided by the browser's autoplay policy, by the reader's motion preference, and by whether the tab is even in front of the user. A production implementation is a small gate with several independent inputs, and the intersection ratio is only one of them.
Those inputs are:
- Is enough of it on screen? The intersection ratio, against a threshold high enough that a two-pixel sliver at the viewport edge does not count.
- Is the document actually being looked at?
document.visibilityState, because a hidden tab still reports the same intersection. - Does the reader want motion?
prefers-reduced-motion, which is not advisory. - Will the browser permit it? The autoplay policy, which requires
mutedand, on mobile,playsinline.
Only when all four agree should play() be called. This is the same discipline that separates a visible element from a seen one in analytics work, and for the same reason: the geometry answer is necessary but never sufficient.
Spec / Signature Reference Table
| Input | Where it comes from | Failure if ignored |
|---|---|---|
entry.intersectionRatio |
The observer callback | A sliver at the viewport edge starts playback |
document.visibilityState |
visibilitychange event |
Video decodes in a backgrounded tab |
matchMedia('(prefers-reduced-motion: reduce)') |
OS preference, live-updating | Unrequested motion for readers who asked for none |
muted attribute |
Markup | play() rejects with NotAllowedError |
playsinline attribute |
Markup | iOS takes the video fullscreen instead of playing in place |
preload="none" / "metadata" |
Markup | Every video on the page downloads before anyone scrolls |
video.play() return value |
A Promise<void> |
Un-awaited, a fast scroll produces AbortError |
Step-by-Step Implementation
1. Get the markup right first
<video
class="js-viewport-video"
muted
playsinline
loop
preload="none"
poster="/media/hero-poster.jpg"
width="960" height="540">
<source src="/media/hero.webm" type="video/webm">
<source src="/media/hero.mp4" type="video/mp4">
</video>
preload="none" matters as much as the observer. Without it, a page with six autoplay-on-scroll videos begins six media downloads during load, competing with the Largest Contentful Paint image for bandwidth. The explicit width/height reserve space so the poster swap does not shift layout.
2. Build the gate, not just the callback
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
class ViewportVideo {
private inView = false;
private pending: Promise<void> | null = null;
constructor(private el: HTMLVideoElement) {}
setInView(next: boolean): void { this.inView = next; this.reconcile(); }
/** Single place that decides play vs. pause — every input routes through here. */
async reconcile(): Promise<void> {
const shouldPlay = this.inView
&& document.visibilityState === 'visible'
&& !reduced.matches;
if (shouldPlay === !this.el.paused) return; // already in the desired state
if (shouldPlay) {
try {
this.pending = this.el.play();
await this.pending;
} catch (err) {
if ((err as DOMException).name !== 'AbortError') {
this.el.controls = true; // policy said no — hand control back
}
} finally {
this.pending = null;
}
} else {
if (this.pending) await this.pending.catch(() => {}); // never pause a pending play()
this.el.pause();
}
}
}
The reconcile() shape is the important part. Every input — the observer, the visibility listener, the media-query change — calls the same method, so there is exactly one place where the decision is made and no possibility of two inputs disagreeing.
3. Wire the observer and the other signals
const videos = new WeakMap<Element, ViewportVideo>();
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
videos.get(entry.target)?.setInView(entry.intersectionRatio >= 0.5);
}
}, { threshold: [0, 0.5] });
document.querySelectorAll<HTMLVideoElement>('.js-viewport-video').forEach((el) => {
videos.set(el, new ViewportVideo(el));
observer.observe(el);
});
const all = () => document.querySelectorAll<HTMLVideoElement>('.js-viewport-video');
document.addEventListener('visibilitychange', () => all().forEach((el) => videos.get(el)?.reconcile()));
reduced.addEventListener('change', () => all().forEach((el) => videos.get(el)?.reconcile()));
The threshold: [0, 0.5] array gives a callback both when the element first touches the viewport and when it crosses the halfway mark, so the gate has the resolution it needs without the callback volume of a stepped array.
Configuration Variants
| Scenario | Threshold | Additional gating |
|---|---|---|
| Background hero loop | 0.25 |
Reduced motion; no sound ever |
| A short product clip in a card grid | 0.6 |
Only one playing at a time — pause the others |
| A long-form video the reader chose | do not autoplay | Observer only pauses on exit; entry never starts it |
| Muted preview that unmutes on click | 0.5 |
Once unmuted, disable the auto-pause so scrolling does not stop it |
| Data-saver connection | 1.0, or skip entirely |
navigator.connection.saveData gates the whole feature |
The "only one at a time" variant is worth calling out because a grid of autoplaying cards is where battery and decode cost multiply. Keeping a module-level reference to the currently playing element and pausing it when a new one qualifies costs three lines and removes the worst case entirely.
Edge Cases & Gotchas
A video inside a position: sticky container. The element never leaves the viewport, so it never pauses. Observe a sentinel in the normal flow instead, exactly as with sticky headers.
iOS low-power mode. The system refuses autoplay even for muted, inline video. The promise rejects with NotAllowedError, and the correct response is to reveal the poster and controls rather than retry — a retry loop drains the battery the mode exists to protect.
loop plus a high threshold. A short clip that finishes while partially scrolled out will restart, which reads as a glitch. Either lower the pause threshold below the play threshold, giving hysteresis, or drop loop and let it end.
Reduced motion can change mid-session. The media query is live. Listening for its change event and reconciling is two lines, and without them a reader who enables the preference keeps watching motion until they reload.
Muting is not a scroll-away substitute. Leaving a video playing but silent still decodes every frame. Pausing is the only thing that stops the work.
Third-party embeds do not expose play(). A YouTube or Vimeo iframe has no media element to control; you either use their postMessage API or, more cheaply, defer the whole iframe until it is needed — the approach set out in lazy loading YouTube iframes.
Cost & Battery Budget
Autoplay-on-scroll is one of the few viewport patterns whose cost is paid by the reader's hardware rather than by your server, which makes it easy to under-account for during development on a plugged-in laptop.
Video decode is the dominant term. A 1080p H.264 stream decoded in software occupies a meaningful share of a mobile CPU for as long as playback continues, and hardware decode moves that work rather than removing it. The practical consequence is that the number of simultaneously playing videos matters far more than the resolution of any one of them: two playing clips are close to twice the cost, whereas halving a clip's resolution saves considerably less than half. That is the argument for the one-at-a-time variant above, and it is the argument for pausing aggressively on exit rather than merely muting.
Network cost compounds the same way. A muted background loop that nobody watches still streams, and on a metered connection those are bytes the reader is paying for. preload="none" prevents the download before the first play, but it does nothing once playback starts, so a video that plays through several loops while the reader is reading text further down the page keeps transferring. Pausing stops the transfer as well as the decode, which is why the exit branch is not merely tidiness.
There is a third cost that is easy to miss: compositing. A playing video forces its layer to be repainted on every frame, and on a page that also runs scroll-driven effects that repaint budget is shared with everything else. If a scroll-linked animation stutters only on pages with video, the videos are the first thing to test by disabling — before reaching for the callback optimisations that would be the usual suspects.
A reasonable default budget for a content page is one autoplaying element at a time, muted, at no more than 720p, paused whenever the element falls below the play threshold or the tab is hidden. Anything beyond that should be a deliberate decision with a measurement behind it, taken on a mid-range phone rather than a development machine, and ideally checked against the longtask and event entries described in PerformanceObserver & rendering metrics so the effect on interaction latency is visible rather than assumed.
Framework Integration Patterns
React. Keep the ViewportVideo instance in a ref rather than state — playback status is not render state, and putting it in state re-renders the tree on every scroll past. A callback ref registers and unregisters the element with the shared observer, which is more robust than a mount effect.
Vue. A useViewportVideo composable that takes a template ref and returns { isPlaying } as a readonly ref matches the composable conventions used elsewhere on this site, with the reconcile method kept private.
Angular. An attribute directive on the video element, constructed outside the zone so scroll-driven callbacks do not trigger change detection, re-entering only to emit a playbackChange output.
Debugging Checklist
play()rejects immediately withNotAllowedError.mutedorplaysinlineis missing, or was set from script after the element was created. Put both in the markup.- Console fills with
AbortError. Playback is being paused while aplay()promise is pending. Await it, as inreconcile()above. - Video plays with the tab in the background. No
visibilitychangelistener. The observer alone cannot see this. - Playback starts when only a corner is visible. The threshold is
0— the default. Raise it. - Nothing plays on iOS but everything works on desktop. Low-power mode, or a missing
playsinlinesending the video fullscreen instead. - Battery drain persists after scrolling away. Check that the exit branch actually reaches
pause()and is not short-circuited by theshouldPlay === !pausedguard while a promise is pending.
FAQ
Why does play() fail even though the video is clearly visible?
Visibility is not the gate — the autoplay policy is. Browsers only allow unprompted playback for media that is muted and, on mobile, marked playsinline. If either attribute is missing the returned promise rejects with NotAllowedError no matter what the observer reports. Set both in the markup rather than in script, so the browser sees them before it evaluates the request.
What causes AbortError: The play() request was interrupted?
A fast scroll that enters and leaves the viewport within a few hundred milliseconds calls play() and then pause() before the first frame has been decoded. play() returns a promise, and pausing while it is pending rejects it. The fix is to keep the pending promise and await it before pausing, or to track intent in a variable and reconcile once the promise settles.
Should autoplay-on-scroll respect prefers-reduced-motion?
Yes. Video that starts on its own is unrequested motion, and for readers with vestibular sensitivity it is exactly what the preference exists to prevent. Treat the preference as a hard gate: leave the poster frame and controls visible so playback remains one deliberate click away.
Is an observer enough, or do I also need visibilitychange?
Both are needed. An IntersectionObserver reports the element's position within its document, and switching browser tabs does not change that — a video scrolled into view keeps its ratio while the tab is hidden and would carry on decoding. Pair the observer with a visibilitychange listener and require both signals before playing.
How much does an off-screen playing video actually cost?
Decoding continues for as long as playback does, which on a laptop is measurable battery drain and on a metered connection is real bytes for content nobody is watching. Several autoplaying videos on one page compound both, which is why pausing on exit matters at least as much as playing on entry.
Related
- Autoplaying Video Only When in Viewport — the minimal correct implementation, end to end
- Pausing Background Video to Save Battery — measuring the cost of the pause you skipped
- Lazy Loading YouTube iframes with IntersectionObserver — deferring an embed you cannot control directly
- Measuring Video View Time with IntersectionObserver — turning playback into a defensible view metric
- Lazy Loading Images & Media — the deferral pattern these pages build on
↑ Back to Implementation Patterns for Viewport & Resize Tracking