The feature is four lines of observer code and about thirty lines of everything else. This page is the everything else, in the order it has to happen.

Problem / Scenario Context

A landing page has three short muted loops, one per section. The requirement is simple: play when the reader reaches a section, stop when they leave. The first implementation is equally simple:

TypeScript
new IntersectionObserver(([entry]) => {
  const video = entry.target as HTMLVideoElement;
  entry.isIntersecting ? video.play() : video.pause();
}).observe(video);

It works on the developer's machine and then produces three separate production reports: nothing plays on some phones, the console fills with AbortError on fast scrolls, and a reader with reduced motion enabled sees all three loops start anyway. Each of those is a different missing piece, and none of them is in the observer.

Mechanics Explanation

Three independent systems have to agree before a frame is decoded.

The autoplay policy. Browsers permit unprompted playback only for media that is muted; on mobile they additionally require playsinline, or the video is taken fullscreen instead. When either is missing, play() returns a promise that rejects with NotAllowedError — visibility is irrelevant.

The play() promise. play() is asynchronous because the browser may need to decode a first frame. Calling pause() while that promise is pending rejects it with AbortError. A scroll fast enough to enter and leave within a few hundred milliseconds does exactly that, which is why the error appears in the field and never during development.

The reader's motion preference. prefers-reduced-motion: reduce is a request not to animate. Video that starts by itself is unrequested motion, and honouring the preference means not starting it at all rather than starting it more gently.

Three Systems, Checked in OrderA sequence from markup to playback. The markup must declare muted and playsinline before the browser will consider the request at all. The motion preference is checked next, and reduce stops the sequence with the poster and controls left visible. Only then does the intersection threshold decide, and the play promise must be awaited before any pause can be issued.1. markupmuted playsinline2. motion preferencereduce → stop here3. intersectionratio >= 0.5await play()keep the promiseSkip step 1 and every later step is wasted: the promise rejects with NotAllowedError regardless of what the observer saw.The observer is step three of four. Most bugs in this feature live in the other three.The order matters, because each step can end the sequence

Comparison Table: Threshold Choice

Threshold Starts when Suits
0 a single pixel is visible nothing — a video at the screen edge starts and immediately stops
0.25 a quarter is showing tall background loops that fill the viewport
0.5 half is showing the sensible default for a card or section video
0.75 most of it is showing a video that is the section's main content
1.0 fully visible short clips in a grid, where only the focused one should run

Using two thresholds — a higher one to start and a lower one to stop — gives hysteresis and stops a video toggling while the reader hovers near the boundary. threshold: [0.35, 0.5] with a play test of >= 0.5 and a pause test of < 0.35 costs nothing and removes the flicker.

Minimal Reproducible Example

HTML
<!-- Missing muted: play() rejects and nothing ever runs -->
<video src="/loop.mp4" loop playsinline></video>
TypeScript
new IntersectionObserver(([e]) => {
  const v = e.target as HTMLVideoElement;
  e.isIntersecting ? v.play() : v.pause();     // un-awaited: AbortError on a fast scroll
}, { threshold: 0 }).observe(video);           // threshold 0: starts at the screen edge

Production-Safe Solution

HTML
<video class="js-vp-video"
       muted playsinline loop preload="none"
       poster="/loop-poster.jpg" width="960" height="540">
  <source src="/loop.webm" type="video/webm">
  <source src="/loop.mp4"  type="video/mp4">
</video>
TypeScript
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const pending = new WeakMap<HTMLVideoElement, Promise<void>>();

async function setPlaying(video: HTMLVideoElement, wanted: boolean): Promise<void> {
  const allowed = wanted && !reduce.matches && document.visibilityState === 'visible';
  if (allowed === !video.paused) return;                    // already correct

  if (allowed) {
    const p = video.play();
    pending.set(video, p);
    try { await p; }
    catch (err) {
      if ((err as DOMException).name !== 'AbortError') video.controls = true;  // policy refused
    }
    finally { pending.delete(video); }
  } else {
    const p = pending.get(video);
    if (p) await p.catch(() => {});                         // never pause a pending play
    video.pause();
  }
}

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const video = entry.target as HTMLVideoElement;
    if (entry.intersectionRatio >= 0.5) void setPlaying(video, true);
    else if (entry.intersectionRatio < 0.35) void setPlaying(video, false);
  }
}, { threshold: [0.35, 0.5] });

const videos = [...document.querySelectorAll<HTMLVideoElement>('.js-vp-video')];
videos.forEach((v) => observer.observe(v));

// the two signals the observer cannot see
const resync = () => videos.forEach((v) => void setPlaying(v, !v.paused));
document.addEventListener('visibilitychange', () =>
  videos.forEach((v) => { if (document.visibilityState !== 'visible') void setPlaying(v, false); }));
reduce.addEventListener('change', resync);

One shared observer for every video on the page, per the pooling rationale, and a WeakMap holding the pending promise so a removed element does not retain it.

Hysteresis: Separate Play and Pause ThresholdsA ratio axis with a single threshold at one half. Small scroll movements around that line flip playback repeatedly. Below it, the same axis with a play threshold at one half and a pause threshold at 0.35 creates a band in which no state change occurs, so the same small movements produce no toggling at all.one threshold — the boundary is a knife edge0.5play, pause, play — audible and visible flickertwo thresholds — a band with no state change0.350.5all three readings inside the band — playback never toggles

The Fallback When the Policy RefusesThree outcomes of a rejected play request and the correct response to each. An AbortError means a pause interrupted a pending play and should be ignored. A NotAllowedError means the policy refused and the poster and controls should be revealed. Any other error should surface, since it indicates a genuine media failure.Handling a rejected play() by its reasonAbortErrorA pause interrupted a pending play. Expected on fast scrolls — ignore it.NotAllowedErrorThe policy refused. Reveal the poster and native controls; do not retry.anything elseA genuine media failure. Surface it — a silent catch hides a broken source.

Verification Steps

  • Check the promise path: scroll rapidly past a video ten times with the console open; there should be no AbortError.
  • Check the policy path: remove muted in DevTools and confirm the code reveals controls rather than failing silently.
  • Check the motion preference: enable reduce-motion at the OS level with the page open; playback should stop without a reload.
  • Check the tab path: start playback, switch tabs, and confirm in the Network panel that transfer stops.
  • Check on a real phone: low-power mode is the one condition that cannot be emulated, and it is the most common field failure.

Common Mistakes to Avoid

  • Setting muted from JavaScript after creating the element. The policy is evaluated against the element's state at the time of the request; put it in the markup.
  • Using threshold: 0. A video that starts when one pixel is visible will start and stop repeatedly on any scroll past it.
  • Ignoring preload. Without preload="none" every video on the page starts downloading before anyone scrolls, competing with the LCP image.
  • Retrying a rejected play(). On iOS low-power mode the retry loop drains the battery the mode exists to preserve. Show controls and stop.

FAQ

Why does nothing play on iPhones when it works on desktop?

Either playsinline is missing, in which case iOS takes the video fullscreen rather than playing in place, or the device is in low-power mode, where the system refuses autoplay entirely. Neither is something the observer can influence; the correct response to the second is to reveal the poster and controls.

Do I need to await play() even though I ignore its result?

Yes, if anything might call pause soon afterwards — which on a scroll-driven feature it certainly might. play returns a promise that rejects if the media is paused while it is still pending, so keeping the promise and awaiting it before pausing is what removes AbortError from the console.

What threshold should I start with?

Half is a sensible default for a section or card video. Pair it with a slightly lower pause threshold so a reader lingering at the boundary does not cause repeated toggling, which is more distracting than either state on its own.

Should a reduced-motion reader ever see autoplay?

No. Treat the preference as a hard gate rather than a hint. Leave the poster frame and native controls in place so playback stays available as a deliberate choice, which is both more respectful and simpler to implement than a reduced-intensity variant.


↑ Back to Media Playback Visibility Control