← All articles
Core Web Vitals for Animation-Heavy Sites
Performance for animated websites without killing the motion. How to protect LCP, INP and CLS on GSAP, Lenis and WebGL builds — with real code and numbers.

The usual advice for fixing Core Web Vitals is "remove the animation." That is not advice — it is surrender. I ship Awwwards-level sites with pinned scroll sections, WebGL displacement transitions and heavy GSAP timelines, and they still pass Core Web Vitals. Motion and performance are not opposites; they fight only when the animation is built carelessly. This guide is the practical version of how I keep performance for animated websites green: what each metric actually measures, the handful of rules that matter, and the code I reach for on every project.
If you only remember one thing: animate transform and opacity, never anything that triggers layout, and keep your heavy JavaScript off the critical path. Everything below is detail on those two ideas.
What Core Web Vitals measure (and what motion threatens)
Three metrics, three different failure modes. Animation interacts with all three differently, so treat them separately.
| Metric | Measures | "Good" threshold | What animation threatens |
|---|---|---|---|
| LCP (Largest Contentful Paint) | Time until the largest element renders | ≤ 2.5s | Heavy JS (GSAP, Three.js) blocking the main thread before the hero paints |
| INP (Interaction to Next Paint) | Responsiveness across the whole visit | ≤ 200ms | Scroll/pointer handlers and rAF loops hogging the main thread |
| CLS (Cumulative Layout Shift) | Unexpected layout movement | ≤ 0.1 | will-change, pinning, and entrance animations that reflow content |
INP replaced FID as a stable Core Web Vital in 2024, and it is the one most animation-heavy sites fail. FID only measured the first interaction's input delay; INP looks at the latency of (nearly) every interaction across the session, including the time to paint the result. A scroll-jacked site with a busy requestAnimationFrame loop can feel laggy on every tap — that is exactly what INP now catches.
Rule 1: animate transform and opacity, nothing else
The browser renders in a pipeline: style → layout → paint → composite. Animating a property that changes geometry (width, height, top, left, margin) forces layout — the most expensive stage — on every frame, and it cascades to paint and composite. Animating transform and opacity skips straight to the compositor, which can run on the GPU and often off the main thread entirely.
// Janky: animating `left` reflows the page every frame
gsap.to(".card", { left: 400, duration: 1 }); // ❌ triggers layout
// Smooth: translate is composited, no layout
gsap.to(".card", { x: 400, duration: 1 }); // ✅ transform: translateX
GSAP's x/y shorthands compile to transform, so prefer them over positional CSS. For sizing illusions, animate scale instead of width/height. For reveals, animate opacity and clipPath (composited) rather than toggling display or animating height.
The "FLIP" technique is the escape hatch when you genuinely must move an element between layouts: measure first and last positions, then animate the transform delta — never the layout property itself. GSAP ships Flip for exactly this.
Rule 2: will-change discipline (it is not free)
will-change promotes an element to its own compositor layer ahead of time, which removes the first-frame hitch. But each layer costs GPU memory, and a page littered with will-change: transform can increase CLS and memory pressure, sometimes crashing mobile Safari. Treat it like a controlled substance.
// Promote only right before animating, demote right after.
const el = document.querySelector(".hero");
el.style.willChange = "transform";
gsap.to(el, {
x: 200,
duration: 0.8,
onComplete: () => {
el.style.willChange = "auto"; // release the layer
},
});
Never put will-change on dozens of elements in CSS permanently. Add it just-in-time, remove it on complete. If you use GSAP, it already applies transform hints; you rarely need to set will-change yourself for short tweens.
Rule 3: do not thrash layout in your scroll loop
Layout thrash is reading a geometry property (offsetTop, getBoundingClientRect, scrollHeight) and then writing a style in the same frame — forcing the browser to recompute layout synchronously, repeatedly. In a scroll handler that runs 60+ times a second, this is an INP killer.
// ❌ Read → write → read → write forces sync layout each iteration
items.forEach((el) => {
const top = el.getBoundingClientRect().top; // read
el.style.transform = `translateY(${top * 0.2}px)`; // write
});
// ✅ Batch all reads, then all writes
const tops = items.map((el) => el.getBoundingClientRect().top); // read phase
items.forEach((el, i) => {
el.style.transform = `translateY(${tops[i] * 0.2}px)`; // write phase
});
Better still: let a tool that already batches do it. ScrollTrigger caches measurements and updates on a single shared rAF tick — see my GSAP ScrollTrigger tutorial on pin, scrub and parallax for the pinning and scrub patterns that avoid manual measurement. And drive your smooth scroll through a single loop, as in smooth scroll in Next.js with GSAP and Lenis — never run Lenis's rAF and a separate ScrollTrigger ticker.
Rule 4: protect LCP — keep heavy JS off the critical path
The hero is almost always your LCP element. If a 400KB Three.js bundle parses before the hero text paints, your LCP tanks regardless of how fast your server is. The fix is sequencing.
Render the LCP element in HTML/CSS, hydrate motion after. Let the hero headline and image paint from server-rendered markup with CSS, then attach GSAP/WebGL once the page is interactive.
Lazy-load WebGL and Three.js. A canvas below the fold, or even a hero canvas, can be dynamically imported so its bundle never blocks first paint:
// Next.js: defer the WebGL scene out of the initial bundle
import dynamic from "next/dynamic";
const Scene = dynamic(() => import("@/components/Scene"), {
ssr: false,
loading: () => <div className="hero-fallback" />, // paints instantly, no CLS
});
Gate the canvas on visibility. Don't initialise a Three.js renderer until it scrolls into view, and pause its render loop when off-screen:
const io = new IntersectionObserver(([entry]) => {
entry.isIntersecting ? renderer.setAnimationLoop(tick) : renderer.setAnimationLoop(null);
});
io.observe(canvas);
That single pattern — stop rendering frames you cannot see — is the biggest INP and battery win on a WebGL-heavy page. On a recent build, gating three off-screen canvases dropped idle main-thread work by roughly two-thirds [VERIFY exact figure].
Defer non-critical JS. Anything not needed for first paint — analytics, cursor effects, secondary timelines — should load with next/script strategy="lazyOnload" or after an requestIdleCallback.
Rule 5: kill CLS from entrance animations and fonts
Most animation-related CLS comes from three sources:
- Animating layout on entrance. A section that animates
height: 0 → autoshifts everything below it. Animatetransform/opacityinstead, and reserve space withmin-height. - Late-loading media without dimensions. Always set
width/height(oraspect-ratio) on images and canvases so the browser reserves the box before the asset arrives. - Web fonts. Use
font-display: optionalorswapwith a metric-matched fallback so the headline doesn't reflow when the brand font loads — a reflow that doubles as a visible jump on hero animations.
Reserve space, never let motion create it.
Rule 6: respect prefers-reduced-motion
This is both an accessibility requirement and a performance escape valve: users who opt out of motion get a calmer, lighter page, and your field INP improves for that cohort. Wire it once, globally.
import gsap from "gsap";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
gsap.matchMedia().add(
{ reduce: "(prefers-reduced-motion: reduce)", ok: "(prefers-reduced-motion: no-preference)" },
(ctx) => {
const { reduce } = ctx.conditions as { reduce: boolean };
if (reduce) {
gsap.set(".reveal", { opacity: 1, y: 0 }); // show final state, no animation
return;
}
gsap.from(".reveal", { opacity: 0, y: 40, stagger: 0.1 });
}
);
For WebGL, the reduced-motion branch should skip auto-rotation and continuous shaders entirely, or render a static poster image. The CSS equivalent belongs in your reset: @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }.
Measuring: lab vs field, and what to trust
Two kinds of data, and beginners conflate them.
- Lab (Lighthouse / DevTools): a single synthetic run. Great for debugging why something is slow — open DevTools → Performance, record an interaction, and look for long tasks (>50ms) in your scroll handler. Lighthouse reports a Total Blocking Time proxy, not real INP.
- Field (CrUX / real users): the 75th percentile of actual visitors over 28 days. This is what Google ranks on. Capture it yourself with the
web-vitalslibrary so you see INP on your real audience and devices:
import { onLCP, onINP, onCLS } from "web-vitals";
onLCP(console.log);
onINP(console.log); // watch this one on mid-range Android
onCLS(console.log);
Always test on a throttled mid-range Android in DevTools (4x CPU slowdown), not your M-series laptop. Animation that is buttery on a desktop GPU can blow the 200ms INP budget on a real phone. For the broader Next.js performance picture this sits inside — bundle splitting, images, rendering strategy — these motion rules are one pillar of a fast build.
FAQ
Does GSAP hurt Core Web Vitals?
Not inherently. GSAP animates via transform/opacity by default and runs on a single optimised rAF tick, which is exactly what you want. It hurts CWV only when you animate layout properties, run multiple ticker loops, or load the whole bundle (plus plugins) on the critical path. Import only the plugins you use and initialise after first paint.
Is WebGL bad for Core Web Vitals?
No, but it is heavy, so it must be deferred and gated. Lazy-load the Three.js bundle, don't let the canvas be your LCP element if you can avoid it, pause the render loop off-screen, and offer a reduced-motion/static fallback. Done that way, a WebGL hero can pass all three metrics.
Which metric do animated sites fail most?
INP, by a wide margin. Heavy scroll handlers, layout thrash and continuous rAF loops keep the main thread busy, so taps and clicks wait for the next paint. Batch DOM reads/writes, throttle work to one loop, and pause invisible animations.
Can I animate height or width if I really need to?
Prefer not to. Use transform: scale() for size changes, or the FLIP technique (animate the transform delta between two measured states) when you must move between layouts. GSAP's Flip plugin automates this without per-frame layout cost.
Fast motion is a discipline, not a compromise. If you want a site that wins awards and passes Core Web Vitals on a mid-range phone, that is exactly the work I do — see the Iventions award-winning events website case study for a real example, or read how much an animated website costs before you scope one. Ready to build one? Here's how I can help.
External reference: web.dev — Core Web Vitals.