Denshin / Blog / Engineering
Heavy motion, cheap phones: we tier the animation instead of cutting it
Our GSAP heavy homepage stuttered on low end phones. Instead of stripping it, we pick full, lite or static motion per device, with a frame time watchdog, a URL switch for testing, and two ScrollTrigger traps that left content invisible.
Denshin Engineering · Engineering Team · 13 October 2026 · 5 min read
The homepage of this site moves a lot. The logo morphs as you scroll, sections pin and release, headlines reveal line by line, there is blur, and on a desktop there is a custom cursor. It is built with GSAP and ScrollTrigger, and on a recent laptop it is smooth.
On low end phones it was not. Entrances juddered, the first scroll after load hitched, and sometimes text arrived a beat after it should have. The usual advice at that point is to remove animation until the slowest device is happy. We did not want a site designed for the weakest phone and merely tolerated on everything else, so we went the other way: one page, three levels of motion, picked per device.
Three tiers
Full is everything. Lite keeps the reveals and the layout and drops the expensive parts: no morphing logo, no pinned sections, no blur, no custom cursor. Static is for people who have asked their operating system to reduce motion, and it shows every element in its finished state without moving anything.
The choice happens once, early. Stripped down, it looks like this:
function motionTier() {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return 'static'
const cores = navigator.hardwareConcurrency ?? 8
const memory = navigator.deviceMemory ?? 8
const saveData = navigator.connection?.saveData === true
if (cores <= 4 || memory <= 4 || saveData) return 'lite'
return 'full'
}
The tier lands as a class on the page root, so CSS can respond to it as well as JavaScript. deviceMemory and the Save-Data flag are not available in every browser, which is why the defaults lean towards full and something else has to catch the cases the hints miss.
The watchdog
Hardware hints are wrong in both directions. A phone with eight slow cores looks fast. A laptop in battery saver looks fine and is not. So on the full tier a watchdog samples frame timing for roughly the first two seconds, and if frames are consistently slow it downgrades the page to lite and fires an event. Components listening for it tear down their heavy timelines and build the lighter ones.
It bit us twice. First, the watchdog measures everything on the main thread, including our own setup work. An experimental hero effect generated tens of thousands of particle positions synchronously when it mounted, and that alone was enough to trip the downgrade on a fast machine. That kind of setup now runs in requestIdleCallback slices, between frames instead of inside them.
Second, listeners have to re-check the tier when the event fires instead of obeying it blindly, because an explicit override outranks the watchdog. Without that, forcing the full tier for testing could still get frozen by a downgrade, which looks exactly like a broken animation loop and is confusing to debug.
Testing all three
Any tier can be forced from the URL with ?motion=full, ?motion=lite or ?motion=static, and the choice is remembered in local storage so it survives moving between pages. A designer can review the lite experience on their own laptop, and a bug report can carry the exact URL that shows the problem. If you build tiers, build the switch first. Otherwise you only ever test the tier your own device happens to pick.
Smooth scrolling only where it helps
We later added smooth scrolling with Lenis. It runs only on the full tier, and only when the primary pointer is fine and can hover, which in practice means a mouse or trackpad. Touch devices keep native scrolling. Phones already have excellent momentum scrolling, and swapping it for a JavaScript version on a slower CPU is a downgrade dressed up as a feature.
Two GSAP traps
Both of these produce the same symptom, which is why they are worth writing down: content that is in the DOM, has its data, and is invisible.
The first is sharing a target between a scrubbed tween and a delayed entrance tween. The entrance sets the element to opacity zero as its starting point, the scrubbed tween records its own start state from whatever it finds, and depending on the order they initialise the element can stay at zero. Our hero headline did this. The fix was structural: the scrub animates a wrapper, the entrance animates the text inside it, and the two never touch the same element.
The second was ScrollTrigger.config({ limitCallbacks: true }). It reads like a free performance win. What it does is skip callbacks for triggers that are passed over within a single scroll update. If a visitor presses End, follows an anchor link, or the browser restores the scroll position when someone hits back, the page jumps past several triggers at once, their onEnter never fires, and every one shot entrance waiting on it stays at opacity zero. For us that showed up as sections that had loaded their data from the API and displayed nothing. We removed the setting and moved entrance start positions to GSAP's clamp() syntax, so a trigger near the top of the page cannot start at a scroll position the page can never reach.
Was it worth it
It is more code than a single animation path, and every new section has to say what it does on each tier. In return, fast laptops get the site as designed, cheaper phones get a site that is quick and still has some life in it, and people who asked for less motion get none. That last group is the one we would least like to get wrong.
Tiering is far cheaper to set up at the start of a motion heavy build than after launch. It now sits on our pre-launch checklist for any page with scroll driven animation.
Tags: GSAP, Performance, Animation, Accessibility, Frontend
All posts · Work with Denshin