Spurlock Studios
Contact
Share LinkedIn X
A small stack of coins. Thesis: MOTION SURVIVES PRODUCTION.

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.

This spoke sits under Websites That Feel Like Films. The parent is the conversion-and-cinema argument. This post is the production list: which properties may move, how you tear them down, and what the page looks like when JavaScript is late. The system-level version is motion systems that ship. How we scope a brand site that has to survive phones is on /websites.

The short answer

  • Animate transform and opacity. Everything else is a layout or paint tax you will feel on a mid-range device.
  • Treat will-change as a loan you take when the tween starts and repay when it ends.
  • Create ScrollTriggers inside gsap.context and revert on cleanup. No exceptions in an SPA.
  • The hero must be readable before the motion bundle runs. Reduced-motion is a designed cut, not a fallback.
  • Lighthouse in the nineties is a static-and-fonts problem first. Motion is what you spend after that bill is paid.

Which CSS properties are cheap to animate?

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. Designers hear that as a constraint on taste. It is a constraint on physics. The compositor can move a layer and fade it. It cannot cheaply reflow a document sixty times a second because you animated top instead of y.

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. The difference is area, frequency, and whether the blur is the scene or a texture on top of a scene that is already still. Full-bleed blur on a scrolling hero is how you thermal-throttle a phone that was fine in the Figma prototype, because the prototype never painted that blur on a warm GPU.

A cheap-property checklist:

  • Position changes are x / y / scale / rotate, not top / left / width / height
  • Fades are opacity, not visibility tricks that still composite a giant bitmap
  • Shadows are static, or they are faked with a pre-blurred asset, not box-shadow on a tween
  • filter is a rare, small, non-scrolling overlay — or it is cut
  • Backgrounds do not pan via background-position; you transform a child
  • Someone can point at the exception in writing if a layout property must move

If the motion pass starts in After Effects and gets “implemented” as layout tweens, you will spend the performance budget on translation. Start in transform language or you will be asked to “just make it like the reel” on a device that cannot.

Why is will-change 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. Promotion is not free. It is a reservation. The browser keeps a bitmap around in case you move it. A page of reserved bitmaps is a page that janks, or a tab that dies, on a phone with no spare RAM.

Set it when a tween starts, remove it when the tween ends. GSAP does this correctly by default. Manual CSS usually does not. The classic failure is a utility class, .will-change-transform, sprinkled on every card “for smoothness.” You just asked a mid-range Android to hold a layer per card, including the ones offscreen, including the ones that never move.

Loan rules I actually use:

  • Promote for the duration of the tween, not for the lifetime of the component.
  • Do not promote children you are not moving. Nested promoted layers multiply cost.
  • If GSAP is in the stack, let it manage the hint. Fighting it with a global CSS rule is how you leak layers.
  • If you must hint in CSS, hint the element that actually tweens, and clear it.

will-change looks like a performance gift because DevTools on a laptop shows a fat compositor layer and a green FPS graph. Production is the device that ran out of layers. Treat the hint like a mutex, not like a vitamin.

How do you kill ScrollTriggers before they leak?

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. The page looks fine until the third route change, then scroll is drunk, then a callback writes to a node that is gone, then you have a ghost pin and a bug you cannot reproduce on first load.

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.

What “completely” means: revert kills the tweens, kills the ScrollTriggers, unwraps pin spacers, and drops the callbacks. Hand-rolled kill() on a subset of triggers is how you miss the one that was created inside a matchMedia branch. Put creation inside the context. Put teardown in the same place the framework already tears the view down.

If you are not in an SPA, you still revert. Fast refresh, back-forward cache, and view transitions will otherwise double-bind. First-load-only thinking is how portfolio videos stay smooth and production sites accumulate listeners.

Kill checklist:

  • Every trigger is created inside a context scoped to the component root
  • Cleanup calls ctx.revert() — not a partial kill() list you remembered
  • Route changes do not leave pin spacers in the document
  • matchMedia branches live inside the same context so they revert together
  • You tested enter, leave, and enter-again, not just first paint

Why must the hero work before 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.

That blank page is not an edge case. It is the hotel wifi case. It is the ad-blocker case. It is the old-phone-that-never-finished-the-chunk case. It is also every prefers-reduced-motion user if your only “visible” state lives in the no-preference timeline.

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. So build it first. Progressive enhancement for motion: the HTML and CSS already say the right thing. JS may add entrance, parallax, a single earned pin. JS may not be the reason the words exist.

This is the same discipline as a late LCP image. If the fold’s job is recognition and a next step — see above the fold that works — then hiding that job until a tween runs is a conversion bug dressed as craft. The reel still looks great. The field user never saw the headline.

Hero rules that survive a dead bundle:

  • Critical copy is in the HTML, visible, in the final layout.
  • Initial CSS does not set the LCP node to opacity: 0.
  • Motion may fade or translate from a nearby state, not from nothing.
  • A JS error leaves a designed page, not a void.
  • The reduced-motion cut is that designed page, not an afterthought stylesheet.

Is reduced motion a fallback or a first-class cut?

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.

“First-class” means you design the still composition. Crop, type, contrast, the one image that carries the fold. You do not take the motion composition and animation: none it into a pile of overlapping absolutely-positioned layers that were only readable in sequence. If the still does not hold, the motion was load-bearing in a way production cannot afford — because a share of your audience asked the OS to cut motion, and browsers will honor that without asking your timeline.

This is craft, not a compliance sticker. The longer argument is accessibility as craft. Here the production rule is smaller: author both cuts in the same PR, watch both on a phone, and never let the reduce branch be “whatever clearProps does.”

What does a 100 Lighthouse score actually require?

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.

The sixties are not a GSAP mystery. They are a waterfall: a blocking script in head, a hero that is a four-megabyte video with no poster discipline, fonts that hide text, images that are the wrong size, hydration of the whole page so a pin can exist. Motion is downstream of those. If you add a transform-only timeline to a page that already failed LCP, you did not create a motion problem. You added a second problem.

Order of operations that actually holds up:

  1. Static HTML for the fold, with the LCP image in the document.
  2. Fonts that swap, not fonts that hide the headline until they load.
  3. No render-blocking JS for first paint.
  4. Hydrate interactive islands when they are needed, not because the homepage is “an app.”
  5. Then spend the motion budget: transform/opacity, loaned will-change, reverted triggers, a designed reduced-motion cut.

Skip a step and the award reel will still play on a laptop. Production is the phone. Production is the point.

Why do cinematic sites feel janky on phones?

Because the reel is a laptop capture and the visitor is a mid-range GPU on a bad network. Layout-property tweens, full-bleed blur, leaked ScrollTriggers, and a hero that stays opacity: 0 until JavaScript runs all show up as jank or a blank fold. Transform and opacity, teardown, and a visible static hero are what survive that contact.

Should reduced-motion users get a worse layout?

No. They should get the layout you were supposed to design anyway: the still composition that already does the job of the fold. gsap.matchMedia is how you author that cut next to the motion cut. If the still is unreadable, the motion was covering a layout problem, and production will uncover it the first time someone has the OS toggle on.

FAQ

What questions does this article answer?

Why do cinematic sites feel janky on phones?
Because the reel is a laptop capture and the visitor is a mid-range GPU on a bad network. Layout-property tweens, full-bleed blur, leaked ScrollTriggers, and a hero that stays `opacity: 0` until JavaScript runs all show up as jank or a blank fold. Transform and opacity, teardown, and a visible static hero are what survive that contact.
Should reduced-motion users get a worse layout?
No. They should get the layout you were supposed to design anyway: the still composition that already does the job of the fold. `gsap.matchMedia` is how you author that cut next to the motion cut. If the still is unreadable, the motion was covering a layout problem, and production will uncover it the first time someone has the OS toggle on.

Last reviewed

More from this lane

Websites

All →
Start a sprint