1interface ObservedAnimation {
2 element: HTMLElement | SVGElement;
3 intersecting: boolean;
4}
5
6const animations = new Map<Element, ObservedAnimation>();
7let observer: IntersectionObserver | null = null;
8let reducedMotion: MediaQueryList | null = null;
9
10function updateAnimation(animation: ObservedAnimation) {
11 const running =
12 animation.intersecting && document.visibilityState === "visible" && !reducedMotion?.matches;
13 animation.element.style.setProperty("--visible-animation-state", running ? "running" : "paused");
14 animation.element.style.setProperty(
15 "--visible-animation-will-change",
16 running ? "transform" : "auto",
17 );
18}
19
20function updateAnimations() {
21 for (const animation of animations.values()) updateAnimation(animation);
22}
23
24/** Attach to a stable animation container. All refs share visibility and motion listeners. */
25export function observeVisibleAnimation(element: HTMLElement | SVGElement | null) {
26 if (element === null) return;
27 element.style.setProperty("--visible-animation-state", "paused");
28 element.style.setProperty("--visible-animation-will-change", "auto");
29 if (typeof IntersectionObserver === "undefined") return;
30
31 if (observer === null) {
32 reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
33 reducedMotion.addEventListener("change", updateAnimations);
34 document.addEventListener("visibilitychange", updateAnimations);
35 observer = new IntersectionObserver((entries, source) => {
36 if (source !== observer) return;
37 for (const entry of entries) {
38 const animation = animations.get(entry.target);
39 if (!animation) continue;
40 animation.intersecting = entry.isIntersecting;
41 updateAnimation(animation);
42 }
43 });
44 }
45
46 const animation = { element, intersecting: false };
47 animations.set(element, animation);
48 observer.observe(element);
49
50 return () => {
51 if (animations.get(element) !== animation) return;
52 animations.delete(element);
53 observer?.unobserve(element);
54 element.style.setProperty("--visible-animation-state", "paused");
55 element.style.setProperty("--visible-animation-will-change", "auto");
56 if (animations.size === 0) {
57 observer?.disconnect();
58 observer = null;
59 reducedMotion?.removeEventListener("change", updateAnimations);
60 reducedMotion = null;
61 document.removeEventListener("visibilitychange", updateAnimations);
62 }
63 };
64}