Motion That Survives Production
Every award-winning site is fast in the case study and janky on a mid-range Android. Here is what actually holds up once real users arrive.
There is a category of website that looks extraordinary in a portfolio video and falls apart the moment it meets a three-year-old phone on a hotel wifi. I have shipped enough motion-heavy sites to know exactly which decisions cause that, because I made most of them at least once.
Only two properties are cheap
transform and opacity are composited. Everything else is not. Animating width, top, box-shadow, filter or background-position forces layout or paint on every frame, and on a mid-range device you will feel it immediately.
This is not a nuance, it is the whole game. If a motion idea cannot be expressed as transform and opacity, the idea needs to change — not the budget.
The one exception I allow is a filter: blur() on a fixed, small overlay element that animates rarely. Grain qualifies. A blurred hero that animates on scroll does not.
will-change is a loan, not a gift
Putting will-change: transform on everything promotes everything to its own compositor layer, and enough layers will exhaust memory on exactly the devices you were trying to help.
Set it when a tween starts, remove it when the tween ends. GSAP does this correctly by default. Manual CSS usually does not.
Kill your ScrollTriggers
Every pinned section, every scrubbed timeline, every containerAnimation holds references. In an SPA or a framework with client-side routing, an un-killed ScrollTrigger on an unmounted component is a memory leak that also fires callbacks against detached DOM nodes.
useEffect(() => {
const ctx = gsap.context(() => {
/* all triggers created in here */
}, rootRef);
return () => ctx.revert();
}, []);
gsap.context plus revert in cleanup handles this completely. There is no reason to do it any other way.
The hero must work before the motion loads
The most damaging performance mistake is gating content on the animation system. If your headline is opacity: 0 in CSS and only becomes visible when a GSAP timeline runs, then a slow bundle, a failed CDN, or a JavaScript error produces a blank page.
Ship the hero in its final state and let the motion system take over. Under prefers-reduced-motion the static version is the deliverable, which means you have to build it anyway.
Reduced motion is not a fallback
Treating reduced motion as a degraded experience is how you end up with a version nobody tested. gsap.matchMedia lets you author it as a first-class variant:
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: reduce)", () => {
gsap.set(targets, { clearProps: "all", opacity: 1 });
});
mm.add("(prefers-reduced-motion: no-preference)", () => {
/* pins, scrubs, trails */
});
Two authored experiences, one codebase, and the accessible one is not an afterthought that broke six deploys ago.
What “100 Lighthouse” actually requires
Static output, no render-blocking JavaScript, fonts preconnected and swapped, images sized and modern-format, and interactive components hydrated on visibility rather than on load. The motion budget comes after all of that is satisfied — which is the opposite of how most sites are built, and the reason most cinematic sites score in the sixties.