Motion & accessibility
The rules every demo here follows: respect reduced motion, and don't animate what nobody can see.
What it is
Some people get dizzy or nauseous from on-screen motion, so operating systems offer a reduce motion setting, which websites read as prefers-reduced-motion. This lab follows it by default, and the sidebar lets you override it. In reduced mode demos swap big movement for fades and don't start by themselves. Every demo also pauses when it scrolls off screen, and the FPS meter shows whether animations keep up with your screen's refresh rate.
When to use it
- Always: reduced motion is an accessibility requirement (WCAG 2.3.3)
- Pausing off-screen work saves battery on every page with animation
- The FPS meter: spotting the demos that are too heavy for a device
1. One animation, two modes
Switch Animation in the sidebar between Reduce and Full and watch the box change behaviour.
Mode: full (moves and spins) · off screen: paused
2. How the setting reaches every demo
MotionProvider(in the root layout) reads the system setting withmatchMediaand your override fromlocalStorage.- Demos call
useMotion()and getreduced: true | false. - Plain CSS can follow it too: the provider sets
data-motion="reduce"on<html>.
Show code · src/app/motion/MotionDemo.tsx
"use client";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { useRef } from "react";
import { useMotion } from "@/components/motion/MotionProvider";
import { useInView } from "@/components/motion/useInView";
import { Stage } from "@/components/ui";
gsap.registerPlugin(useGSAP);
// One looping animation that follows both rules every demo in the lab uses:
// 1. Reduced motion: no travel across the screen, just a gentle fade.
// 2. Off screen: paused, so it costs nothing while you can't see it.
export function MotionDemo() {
const { reduced } = useMotion();
const stage = useRef<HTMLDivElement>(null);
const tween = useRef<gsap.core.Tween | null>(null);
const inView = useInView(stage);
// useGSAP scopes the animation to this component and reverts it on unmount
// (or when `reduced` changes), so nothing leaks between pages.
useGSAP(() => {
tween.current = reduced
? gsap.fromTo(".box", { opacity: 0.4 }, { opacity: 1, duration: 1.5, repeat: -1, yoyo: true, ease: "sine.inOut" })
: gsap.to(".box", { x: () => (stage.current?.clientWidth ?? 300) - 96, rotation: 360, duration: 2, repeat: -1, yoyo: true, ease: "power2.inOut" });
}, { scope: stage, dependencies: [reduced], revertOnUpdate: true });
useGSAP(() => {
if (inView) tween.current?.resume();
else tween.current?.pause();
}, { dependencies: [inView, reduced] });
return (
<>
<Stage ref={stage} className="h-30 p-7">
<div className="box size-16 rounded-xl bg-accent" />
</Stage>
<p className="text-sm text-muted">
Mode: <strong>{reduced ? "reduced (fade only)" : "full (moves and spins)"}</strong> · {inView ? "on screen: playing" : "off screen: paused"}
</p>
</>
);
}
Show code · src/components/motion/MotionProvider.tsx
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore, type ReactNode } from "react";
// The lab's motion settings, shared by every demo:
// - preference: follow the operating system's "reduce motion" setting, or
// override it (Reduce / Full) for this browser
// - showFps: overlay a frames-per-second meter
// Both are remembered in localStorage. Every demo reads `reduced` and tones
// itself down: no large movement, no autoplay, instant transitions.
export type MotionPreference = "system" | "reduce" | "full";
type MotionSettings = {
preference: MotionPreference;
setPreference: (p: MotionPreference) => void;
systemReduced: boolean;
reduced: boolean;
showFps: boolean;
setShowFps: (on: boolean) => void;
};
const MotionContext = createContext<MotionSettings | null>(null);
const PREF_KEY = "motion-lab:preference";
const FPS_KEY = "motion-lab:fps";
const CHANGE = "motion-lab:settings";
// localStorage can throw (private windows, blocked storage); the lab then
// simply forgets settings between visits.
const read = (key: string) => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
const write = (key: string, value: string) => {
try {
localStorage.setItem(key, value);
} catch {}
window.dispatchEvent(new Event(CHANGE));
};
const subscribeSettings = (onChange: () => void) => {
window.addEventListener(CHANGE, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(CHANGE, onChange);
window.removeEventListener("storage", onChange);
};
};
const reduceQuery = () => window.matchMedia("(prefers-reduced-motion: reduce)");
const subscribeSystem = (onChange: () => void) => {
const mq = reduceQuery();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
};
export function MotionProvider({ children }: { children: ReactNode }) {
// Server snapshots: "system", not reduced, no FPS meter. The real values are
// read on the client right after hydration.
const stored = useSyncExternalStore(subscribeSettings, () => read(PREF_KEY), () => null);
const preference: MotionPreference = stored === "reduce" || stored === "full" ? stored : "system";
const showFps = useSyncExternalStore(subscribeSettings, () => read(FPS_KEY) === "1", () => false);
const systemReduced = useSyncExternalStore(subscribeSystem, () => reduceQuery().matches, () => false);
const reduced = preference === "reduce" || (preference === "system" && systemReduced);
const setPreference = useCallback((p: MotionPreference) => write(PREF_KEY, p), []);
const setShowFps = useCallback((on: boolean) => write(FPS_KEY, on ? "1" : "0"), []);
// Lets plain CSS follow the setting too: [data-motion="reduce"] …
useEffect(() => {
document.documentElement.dataset.motion = reduced ? "reduce" : "full";
}, [reduced]);
const value = useMemo(
() => ({ preference, setPreference, systemReduced, reduced, showFps, setShowFps }),
[preference, setPreference, systemReduced, reduced, showFps, setShowFps],
);
return <MotionContext.Provider value={value}>{children}</MotionContext.Provider>;
}
export function useMotion() {
const settings = useContext(MotionContext);
if (!settings) throw new Error("useMotion must be used inside <MotionProvider>");
return settings;
}
Show code · src/components/motion/useInView.ts
"use client";
import { useEffect, useState, type RefObject } from "react";
// True while the element is on screen. Demos pause themselves when it's
// false, so a page full of animations only spends frames on what's visible.
export function useInView(ref: RefObject<Element | null>, rootMargin = "100px") {
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), { rootMargin });
observer.observe(el);
return () => observer.disconnect();
}, [ref, rootMargin]);
return inView;
}