Pausing Off-Screen and Background-Tab Animations

Part of Main-Thread Scheduling & Long Animation Frames in Performance Budgeting & GPU Architecture.

The problem

A marketing page has a looping gradient in the hero, a ticker of customer logos halfway down, an animated illustration in the footer and a canvas particle effect behind a sign-up form. Each is modest on its own. Together they keep the GPU busy and the main thread waking up for as long as the page is open — including when the user has scrolled to the pricing table, and including when the tab has been in the background for an hour.

The cost shows up as battery drain on laptops and phones, fans spinning on a page that looks static, and jank in the one animation the user is actually looking at, because it is sharing frames with three they are not.

Root cause: continuous motion has no natural end

A transition runs once and stops. An animation-iteration-count: infinite animation, a requestAnimationFrame loop that re-schedules itself and a setInterval that updates the DOM have no end condition, so they cost something on every frame for the lifetime of the page unless something stops them.

Browsers do some of this for you, but not all of it, and the details differ by mechanism.

requestAnimationFrame callbacks stop running in hidden tabs and in iframes that are not rendered. They keep running for elements that are merely scrolled off-screen.

Timers keep running in hidden tabs, heavily throttled — typically to once per second, and to once per minute after a tab has been hidden for a while in some engines. DOM work they do still invalidates style and layout that must be processed on return.

CSS animations in hidden tabs do not produce frames, since nothing is presented. On-screen but scrolled out of view, compositor animations may still tick and keep their layer’s textures alive, and main-thread animations still run style every frame. Engines optimise some off-screen cases, but relying on that is not a strategy.

content-visibility: auto skips style, layout and paint for a subtree while it is off-screen, which stops main-thread animation work inside it, and is the cheapest broad tool for long pages.

What stops by itself, and what you have to stopOff-screen is the gap: only content-visibility or your own code handles it reliably.What stops by itself, and what you have to stopHidden tabScrolled off-screenYour jobrequestAnimationFrameloopStopsKeeps runningIntersectionObserversetInterval DOMupdatesThrottledKeeps runningBoth observersInfinite CSSanimationNo framesMay tickanimation-play-stateInsidecontent-visibility:autoNo framesSkippedNothing extraAutoplay videoOften keeps decodingKeeps decodingPause the element
Off-screen is the gap: only content-visibility or your own code handles it reliably.

Step-by-step resolution

Making ambient motion stop when unseenOne observer for the viewport, one listener for the tab, one CSS hook to act on both.Making ambient motion stop when unseen1Mark each continuous animation's container with a data attribute such as data-ambient.One selector finds everything to manage2Observe those containers with IntersectionObserver and toggle data-paused.Off-screen motion stops3Listen for visibilitychange and set data-paused on the document while hidden.Timers and loops stop in background tabs4In CSS, map data-paused to animation-play-state: paused.CSS animations freeze in place5In script loops, check the paused flag and stop re-scheduling.No wake-ups at all6On resume, reset the loop's clock so it does not fast-forward.
One observer for the viewport, one listener for the tab, one CSS hook to act on both.

Production code pattern

/* Ambient motion runs only while its container is visible and the tab is shown. */
.ambient-gradient {
  animation: drift 18s linear infinite;
}
[data-paused] .ambient-gradient,
:root[data-tab-hidden] .ambient-gradient {
  animation-play-state: paused;          /* freezes in place; resumes from the same point */
}

/* Long below-the-fold sections skip rendering entirely until near the viewport. */
.page-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 900px;
}

@keyframes drift {
  to { transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .ambient-gradient { animation: none; }  /* ambient motion is decorative: remove it */
}
const ambient = document.querySelectorAll('[data-ambient]');

// 1. Viewport: pause what is off-screen, with a margin so it restarts before it appears.
const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    entry.target.toggleAttribute('data-paused', !entry.isIntersecting);
    entry.target.dispatchEvent(new CustomEvent('ambient:visibility', {
      detail: { visible: entry.isIntersecting },
    }));
  }
}, { rootMargin: '200px 0px' });
ambient.forEach((el) => io.observe(el));

// 2. Tab: pause everything while hidden.
document.addEventListener('visibilitychange', () => {
  document.documentElement.toggleAttribute('data-tab-hidden', document.hidden);
});

// 3. A canvas loop that honours both, and resumes without jumping.
function particleLoop(canvas) {
  let raf = 0;
  let last = 0;
  const tick = (now) => {
    const dt = Math.min(now - last, 32);   // clamp: never simulate a long gap in one step
    last = now;
    step(canvas, dt);
    raf = requestAnimationFrame(tick);
  };
  const start = () => {
    if (raf || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    last = performance.now();
    raf = requestAnimationFrame(tick);
  };
  const stop = () => { cancelAnimationFrame(raf); raf = 0; };
  canvas.addEventListener('ambient:visibility', (e) => (e.detail.visible ? start() : stop()));
  document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
}

Rendering Impact: none while paused. A paused CSS animation is not sampled, a stopped rAF loop does not wake the main thread, and a content-visibility: auto section off-screen is not styled, laid out or painted.

The clamp on dt is what prevents the classic resume bug. Without it, a loop that computes motion from elapsed time sees a gap of several seconds on the first frame after resuming and moves every particle to where it would have been — a visible jump. Clamping treats the gap as a single normal frame.

CPU time per minute with the page scrolled to the pricing tableFour ambient animations, all off-screen. Mid-range laptop on battery.CPU time per minute with the page scrolled to the pricing tableNo management7.4 svisibilitychange only7.4 scontent-visibility: auto on sections2.1 sIntersectionObserver + play-state0.3 s
Four ambient animations, all off-screen. Mid-range laptop on battery.

The second bar is the instructive one: tab-visibility handling does nothing for content the user has scrolled past. Viewport handling is where most of the saving is, and the tab handler is the backstop for the case where the whole page is hidden.

Verification checklist

Constraints and trade-offs

  • animation-play-state: paused freezes mid-cycle; for motion that must restart from the beginning, remove and re-add the animation instead.
  • The rootMargin on the observer trades a little off-screen work for never showing a frozen frame as content scrolls in.
  • content-visibility: auto hides content from find-in-page matching until rendered in some engines and changes scroll height estimates; test both.
  • Animations synchronised across elements drift apart if they are paused independently; pause the group together.
  • Pausing a video element saves decode cost but loses its buffered position on some mobile browsers when combined with memory pressure.

Frequently asked questions

Do browsers pause CSS animations automatically when they are off-screen?

Not reliably. Hidden tabs produce no frames, but for elements that are only scrolled out of view, engines vary in how much work they skip. Pausing explicitly, or placing the content under content-visibility: auto, makes the behaviour predictable.

Is requestAnimationFrame already paused in background tabs?

Yes, callbacks do not run in hidden tabs. They do run for elements scrolled off-screen, so a canvas loop below the fold keeps costing CPU until you stop it.

Should decorative animations run at all under reduced motion?

No. Ambient, looping motion is decorative by definition, and it is exactly what prefers-reduced-motion users ask to avoid. Remove it, and leave a static frame that still looks intentional.

What rootMargin should the IntersectionObserver use?

A margin of one or two hundred pixels is usually enough to restart an animation before it becomes visible during a normal scroll. Larger margins keep more off-screen work alive; very fast flings may still briefly show a frozen frame, which is acceptable for ambient motion.