Animation rarely breaks because pause() is difficult to call. It breaks because several different things can cause motion to stop, and the implementation forgets why it stopped.
Imagine an animated hero that leaves the viewport. You pause it so a render loop is not running several screens below the fold. While it is offscreen, the user switches tabs, their reduced-motion preference is active, or they had already pressed a pause control. When the element returns, blindly calling play() can override one of those states.
A more reliable model is to treat viewport visibility as one input rather than the final decision. Collect the states that matter, derive one shouldAnimate value, and let the animation engine respond to that value.
const shouldAnimate =
inViewport &&
pageVisible &&
motionAllowed &&
!userPaused;
That small separation is what keeps an offscreen optimization from turning into a resume bug.
Pausing is easy. Resuming correctly is harder
A basic offscreen optimization often starts with IntersectionObserver:
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
animation.play();
} else {
animation.pause();
}
});
For a small decorative effect, this may be enough. It becomes fragile once the animation participates in more than one lifecycle.
Viewport visibility answers one question: is this element intersecting the viewport or another observer root? It does not tell you whether the browser tab is hidden, whether the user prefers reduced motion, whether a manual pause button is active, or whether another part of the application has suspended the effect.
isIntersecting is one input, not the final decision. The final decision should belong to your application state, while the animation engine only needs to know whether it should currently run.
Treat visibility as several independent signals
IntersectionObserver is a good starting point because the browser can report when an element enters or leaves the observer root. That makes it useful for sections that should stop doing animation work while they are away from the viewport.
const observer = new IntersectionObserver(
([entry]) => {
setInViewport(entry.isIntersecting);
},
{
threshold: 0,
}
);
The observer only knows about the element's intersection geometry. If the user switches to another browser tab while the animated section remains inside the viewport, that is a separate document-level state.
The Page Visibility API gives you that signal:
function handleVisibilityChange() {
setPageVisible(document.visibilityState === "visible");
}
document.addEventListener(
"visibilitychange",
handleVisibilityChange
);
Browsers already apply their own background-tab optimizations. MDN notes that requestAnimationFrame() callbacks are typically paused in background tabs in most browsers. That still does not solve element-level offscreen work while the document itself remains visible, which is why viewport and document visibility are useful as separate signals.
Reduced motion should also remain separate. prefers-reduced-motion represents a user preference around non-essential motion. It may incidentally reduce work, but performance is not the reason the preference exists.
const media = window.matchMedia(
"(prefers-reduced-motion: reduce)"
);
function handleMotionPreference() {
setMotionAllowed(!media.matches);
}
media.addEventListener(
"change",
handleMotionPreference
);
User intent is the fourth state worth preserving. If somebody explicitly pauses an animation, scrolling it out of view and back again should not normally erase that choice.
With those states separated, the animation engine only has to answer one question: run or stop.
Build the run gate before the animation adapter
The easiest way to create state conflicts is to let every event handler control playback directly. The intersection callback pauses the effect, the visibility listener pauses it again, then one of them resumes it while another condition still says it should remain stopped.
A better architecture lets each event update only the state it owns. One derived value then becomes the source of truth. Here is a React hook for that layer:
"use client";
import {
Dispatch,
RefCallback,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
type UseAnimationGateOptions = {
rootMargin?: string;
threshold?: number | number[];
};
type UseAnimationGateResult = {
ref: RefCallback<Element>;
shouldAnimate: boolean;
inViewport: boolean;
pageVisible: boolean;
motionAllowed: boolean;
userPaused: boolean;
setUserPaused: Dispatch<SetStateAction<boolean>>;
};
export function useAnimationGate(
options: UseAnimationGateOptions = {}
): UseAnimationGateResult {
const {
rootMargin = "0px",
threshold = 0,
} = options;
const [element, setElement] = useState<Element | null>(null);
const [inViewport, setInViewport] = useState(false);
const [pageVisible, setPageVisible] = useState(true);
const [motionAllowed, setMotionAllowed] = useState(true);
const [userPaused, setUserPaused] = useState(false);
const ref = useCallback<RefCallback<Element>>((node) => {
setElement(node);
}, []);
useEffect(() => {
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
setInViewport(entry.isIntersecting);
},
{
rootMargin,
threshold,
}
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [element, rootMargin, threshold]);
useEffect(() => {
const updatePageVisibility = () => {
setPageVisible(
document.visibilityState === "visible"
);
};
updatePageVisibility();
document.addEventListener(
"visibilitychange",
updatePageVisibility
);
return () => {
document.removeEventListener(
"visibilitychange",
updatePageVisibility
);
};
}, []);
useEffect(() => {
const media = window.matchMedia(
"(prefers-reduced-motion: reduce)"
);
const updateMotionPreference = () => {
setMotionAllowed(!media.matches);
};
updateMotionPreference();
media.addEventListener(
"change",
updateMotionPreference
);
return () => {
media.removeEventListener(
"change",
updateMotionPreference
);
};
}, []);
const shouldAnimate =
inViewport &&
pageVisible &&
motionAllowed &&
!userPaused;
return {
ref,
shouldAnimate,
inViewport,
pageVisible,
motionAllowed,
userPaused,
setUserPaused,
};
}
Using a callback ref here avoids assuming that the observed DOM node will never change. If React attaches the ref to a different node, the observer effect receives the new element and cleans up the old observer.
The hook still does not know whether the visual layer uses CSS, requestAnimationFrame, Web Animations, GSAP, or WebGL. That separation is deliberate. Visibility policy belongs above the rendering implementation.
Use IntersectionObserver without creating a visible wake-up
Pausing exactly when the final pixel leaves the viewport sounds precise, but it is not always the best visual behaviour. Some effects need a little time to resume before the user actually sees them again.
rootMargin lets you expand or contract the observer's effective root boundary. An effect that should wake before it reaches the viewport might use something like:
const observer = new IntersectionObserver(callback, {
rootMargin: "200px 0px",
threshold: 0,
});
That does not make 200px an optimal value. A CSS shimmer, a shader-heavy hero, and a scroll-linked sequence can all have different wake-up requirements.
Use the margin as an interaction decision, not a copied performance constant. The useful question is whether the effect can become active early enough that the user never sees it visibly restarting.
Thresholds need the same judgement. threshold: 0 works when any intersection should count, while another effect may only make sense once more of the element is visible. The observer tells you about geometry, not attention, so it should remain one production signal rather than becoming a proxy for everything the user is doing.
Keep the animation loop local to the component
A requestAnimationFrame example becomes unsafe in React if its mutable state lives at module scope. Two instances of the same component could end up sharing a running flag or frame ID, which means one instance can accidentally stop the other's loop.
Keep those values instance-local with refs:
import { useCallback, useEffect, useRef } from "react";
export function useRafLoop(
shouldAnimate: boolean,
update: (delta: number) => void
) {
const frameIdRef = useRef<number | null>(null);
const previousTimeRef = useRef<number | null>(null);
const updateRef = useRef(update);
useEffect(() => {
updateRef.current = update;
}, [update]);
const stop = useCallback(() => {
if (frameIdRef.current !== null) {
cancelAnimationFrame(frameIdRef.current);
frameIdRef.current = null;
}
previousTimeRef.current = null;
}, []);
useEffect(() => {
if (!shouldAnimate) {
stop();
return;
}
let active = true;
const frame = (time: number) => {
if (!active) return;
if (previousTimeRef.current === null) {
previousTimeRef.current = time;
}
const delta =
time - previousTimeRef.current;
previousTimeRef.current = time;
updateRef.current(delta);
frameIdRef.current =
requestAnimationFrame(frame);
};
frameIdRef.current =
requestAnimationFrame(frame);
return () => {
active = false;
stop();
};
}, [shouldAnimate, stop]);
}
Each component instance now owns its own frame ID and previous timestamp. The effect cleanup also makes the loop tolerant of React development Strict Mode, where setup and cleanup may run an extra cycle to expose side-effect problems.
A component can combine the two hooks without letting viewport callbacks control the loop directly:
"use client";
import { useCallback } from "react";
export function AmbientCanvas() {
const {
ref,
shouldAnimate,
userPaused,
setUserPaused,
} = useAnimationGate({
rootMargin: "200px 0px",
});
const update = useCallback((delta: number) => {
// Advance your animation using delta.
}, []);
useRafLoop(shouldAnimate, update);
return (
<section ref={ref}>
<canvas />
<button
type="button"
onClick={() => {
setUserPaused((paused) => !paused);
}}
>
{userPaused ? "Resume animation" : "Pause animation"}
</button>
</section>
);
}
The observer updates inViewport. The Page Visibility API updates pageVisible. The media query updates motionAllowed. The button updates userPaused. None of those concerns owns the animation loop directly.
Pause the work, not only the pixels
Different animation technologies need different pause mechanics. Making a canvas invisible does not necessarily stop the code producing frames behind it.
For a manually managed requestAnimationFrame loop, stopping the scheduling path is the relevant operation. The hook above does that by cancelling the outstanding frame and refusing to create another until shouldAnimate becomes true again.
CSS animations have their own mechanism:
.ambient-shape {
animation: drift 8s linear infinite;
}
.ambient-shape[data-paused="true"] {
animation-play-state: paused;
}
The Web Animations API exposes pause() and play() on an Animation object, which is useful when timeline position needs to be preserved.
For WebGL or Three.js, the important question is whether the scene actually requires continuous rendering. If it owns a render loop, suspend that loop where appropriate rather than merely hiding the canvas.
The run gate can remain the same across all of these implementations. Only the adapter that translates shouldAnimate into engine behaviour needs to change.
Do not let resume create a time warp
Delta-time loops have one extra resume bug: the paused interval can become one enormous frame.
Suppose a canvas animation stores the previous frame timestamp, pauses for thirty seconds, then resumes while still comparing the new timestamp with the timestamp from before the pause. The next update may receive a huge delta.
A particle can jump across the screen, physics can advance too far, or easing logic can appear to snap because the implementation treated the entire paused interval as active animation time.
That is why the useRafLoop() example resets previousTimeRef when it stops. The first frame after resume establishes a fresh timestamp instead of trying to simulate all of the time that passed while the animation was inactive.
This is not a universal requirement for every animation engine. Timeline libraries may already preserve paused position correctly. Their documented lifecycle behaviour should remain the source of truth.
The larger decision is whether the effect should resume from its previous logical state or restart from the beginning. Ambient motion usually benefits from continuity. An entrance effect may reasonably restart under different circumstances. That behaviour should be chosen deliberately rather than becoming an accidental consequence of a visibility callback.
React cleanup is part of the optimization
An offscreen optimization is unfinished if observers, listeners, or render loops survive after the component that created them has unmounted. The page may stop displaying an effect while its background work continues.
Every side effect in the examples above owns its cleanup. IntersectionObserver is disconnected, visibilitychange is removed, the media-query listener is removed, and the component's outstanding animation frame is cancelled.
This matters on applications where sections appear conditionally or routes change without a full document reload. A motion-heavy site can otherwise accumulate work as users move through the experience.
For Next.js, APIs such as window, document, IntersectionObserver, and matchMedia() need to execute on the client. That does not mean every animation component in a Next.js project must be client-only. It means the code that directly depends on browser APIs needs the appropriate client boundary.
Reduced motion is an experience decision
prefers-reduced-motion is easy to misuse in performance code because honoring it often results in less animation work. Its purpose is still to represent a user's motion preference, so the resulting experience should be designed around that preference rather than treated as a cheap-device mode.
For a decorative looping background, reduced motion may mean showing a static visual state. For a transition that communicates a meaningful state change, a shorter or less spatial alternative may be more appropriate than removing every transition entirely.
The underlying information should remain available regardless of the animation path. Text should stay readable, controls should still operate, and required content should not depend on movement to become understandable.
It is also worth avoiding broad accessibility claims because a component responds to one media query. Reduced-motion handling addresses one part of motion accessibility. It does not prove that the component or page is fully accessible.
Test state transitions, not only the happy path
Most visibility bugs appear when states change in an order the implementation did not expect. A page can behave perfectly on first load and still fail once viewport state, tab state, user intent, and component lifecycle start interacting.
Test the animation after scrolling it out of view and back in. Switch to another tab while it is running and return. Manually pause it, scroll away, then come back and verify that the observer did not override the user's choice. If your implementation listens for motion-preference changes, test that path while the page is already open.
Unmount the component and confirm that no observer, listener, or frame loop survives. If the effect depends on expensive rendering, test the production build on the hardware you actually expect people to use, not only the development machine that authored the effect.
Pausing offscreen work is a useful production technique, but it does not prove that an effect is now cheap enough for every device. Profile the actual page before making performance claims.
How we treat offscreen motion at Hyperiux
Hyperiux Vault is a source-first library of creative interaction patterns for React and Next.js. Effects land as editable source in the project, so decisions around timing, rendering, responsive behaviour, reduced motion, and offscreen work can be inspected and adapted in the page where the interaction actually runs.
Different effects need different lifecycle strategies. A CSS text treatment does not carry the same rendering cost as a continuous WebGL scene, which is why our WebGL guidance includes pausing or limiting render loops when scenes are offscreen or the document is hidden.
If you are building a motion-heavy React or Next.js page, you can browse Hyperiux Vault effects. For continuously rendered scenes, the WebGL and 3D effects collection is the more relevant place to start.
Final takeaway
Offscreen animation becomes easier to reason about once viewport visibility stops owning playback directly. Keep viewport intersection, document visibility, reduced-motion preference, and user intent as separate states, then derive the single value the renderer actually needs.
That structure lets a CSS animation, requestAnimationFrame loop, Web Animations timeline, or WebGL scene use the same lifecycle policy without sharing the same implementation. It also gives resume behaviour, cleanup, and accessibility a clear place in the system instead of treating them as patches added after the animation already works.
Top comments (1)
The derived shouldAnimate model is the right shape. Two things I'd add from shipping it.
Reduced motion isn't really a pause, and folding it into the same boolean shows up later. Freezing wherever the animation happened to be leaves a designed-for-motion frame sitting still: a shimmer stopped mid sweep, a carousel halfway between slides. We got support tickets about a skeleton loader that looked like a rendering fault, and it was the reduced-motion branch pausing instead of jumping to a resting state. Every effect needs a defined still frame, which is a design decision rather than a flag. Not hard, just easy to forget.
The other one is that the gate loses why it closed. Coming back after two seconds of scrolling and coming back after twenty minutes in a background tab both read as shouldAnimate flipping true, and they want different resume behaviour. Continue in the first case, snap or restart in the second. Keeping the reason next to the boolean costs nothing and makes your time warp section a lot easier to handle.
The transition testing point is the one I'd underline. Ours broke on a tab switch during an in-flight resume, which no single state test goes near.