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 muted and, 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.

Four Gates Before play() Is CalledFour conditions feeding a single AND gate: enough of the element is intersecting, the document is visible, the reader has not requested reduced motion, and the element satisfies the autoplay policy by being muted and playsinline. Only when all four hold is play called. Any one of them turning false calls pause instead.entry.intersectionRatio >= 0.5visibilityState === 'visible'!prefersReducedMotionmuted && playsinlineANDawait video.play()video.pause()any single gate going false takes this branchThe observer supplies one of four inputs. Treating it as the whole decision is what ships a video that plays in a hidden tab.

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

HTML
<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

TypeScript
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

TypeScript
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.

The play() / pause() Race on a Fast ScrollA timeline of a quick scroll past a video. At 0 milliseconds the element enters view and play is called, returning a pending promise. At 180 milliseconds, before the first frame has decoded, the element leaves view and pause is called. The pending promise rejects with AbortError. The corrected sequence awaits the promise before pausing, so the two operations are ordered rather than racing.un-awaited — the two calls raceplay()decoding first frame…pause() — promise still pendingAbortError: play() was interruptedawaited — the two calls are orderedplay()await the pending promisepause() — runs only after it settlesno rejection, and the element ends in the state the reader expects

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.

One Playing Element at a TimeA grid of six autoplaying cards with a single-playback rule. Only the card that most recently crossed the play threshold is running; the previous one was paused as it lost the position. A note records that without this rule the decode and bandwidth costs multiply by the number of visible cards.A grid rule worth adding: at most one playing elementpausedplayingmost recent to qualifypausedpausedpausedpauseddecodecost ×1Without the rule this grid decodes six streams at once, and both battery and bandwidth scale with the count.

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 with NotAllowedError. muted or playsinline is 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 a play() promise is pending. Await it, as in reconcile() above.
  • Video plays with the tab in the background. No visibilitychange listener. 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 playsinline sending the video fullscreen instead.
  • Battery drain persists after scrolling away. Check that the exit branch actually reaches pause() and is not short-circuited by the shouldPlay === !paused guard 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.


↑ Back to Implementation Patterns for Viewport & Resize Tracking