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.
Step-by-step resolution
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: autosection 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.
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: pausedfreezes mid-cycle; for motion that must restart from the beginning, remove and re-add the animation instead.- The
rootMarginon the observer trades a little off-screen work for never showing a frozen frame as content scrolls in. content-visibility: autohides 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.
Related
- Main-Thread Scheduling & Long Animation Frames — the parent topic
- will-change Lifecycle with IntersectionObserver — the same observer releasing layer promotion
- Providing Pause Controls for Autoplay Motion — the user-facing control that complements automatic pausing