Back to the blog

Core Web Vitals for Animated Websites: Pass LCP, INP and CLS

Core Web Vitals for Animated Websites: Pass LCP, INP and CLS

6 October 2026 — Performance

Published

6 October 2026

Reading time

4 min read

Category

Performance

Tags

  • Core Web Vitals
  • Performance
  • Animation
  • WebGL
  • LCP
  • INP
  • CLS

(Intro)How to keep scroll animations, WebGL and video on a website and still pass Core Web Vitals. The specific causes of slow LCP, INP and CLS, and the fixes.

Animated websites have a reputation for being slow, and often deserve it. But motion and good Core Web Vitals aren't opposites. Most failures come from a short list of causes, and each has a fix that keeps the animation.

This site has a video hero, a WebGL project wall, WebGL type and scroll-driven animation throughout. These are the rules it's built on.

The three metrics, briefly

Metric Measures Good
LCP — Largest Contentful Paint When the main content appears ≤ 2.5 s
INP — Interaction to Next Paint How quickly the page responds to taps and clicks ≤ 200 ms
CLS — Cumulative Layout Shift How much the layout jumps ≤ 0.1

Google judges them on the 75th percentile of real Chrome users (the field data in PageSpeed Insights and Search Console), not on a Lighthouse run on your laptop. Test on a mid-range Android phone over a throttled connection; that's closer to your 75th percentile than a MacBook.

LCP: show something real, early

Don't hide the hero behind an entrance animation

The most common animated-site LCP killer is a hero that starts at opacity: 0 and fades in after JavaScript loads. The browser can't count it as painted until it's visible, so LCP waits for your bundle, then for the animation.

Fix: render the hero's final state in HTML and CSS. Animate secondary elements in, or animate from a state that's already visible (a slight scale or translate, not from zero opacity).

Give the LCP element a poster and priority

If the hero is a video or a WebGL canvas, the LCP candidate is usually its poster image. Make that image small, preloaded and high priority:

html
<link rel="preload" as="image" href="/hero/poster.avif" fetchpriority="high" />
<img src="/hero/poster.avif" alt="" fetchpriority="high" width="1600" height="900" />

Start the video or the WebGL scene after the poster has painted, and cross-fade to it.

Load heavy libraries when they're needed

Three.js, GSAP plugins and Lottie don't need to be in the first bundle if they power sections below the fold. Import them dynamically when the section approaches the viewport:

ts
const observer = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting) return
  observer.disconnect()
  const { initScene } = await import('./scene')
  initScene(canvas)
}, { rootMargin: '400px 0px' })
observer.observe(section)

In Next.js, next/dynamic with ssr: false does the same for whole components.

Serve right-sized media

A 1440p video loop on a phone is wasted bandwidth that competes with your LCP image. Pick sources by viewport width and connection, and keep hero loops short (6–10 seconds, no audio track).

INP: keep the main thread free

INP measures the delay between an interaction and the next frame. Animation hurts it when JavaScript is busy every frame.

Animate transform and opacity only

They're handled by the compositor and don't trigger layout. Animating top, left, width, height or margin forces layout on every frame:

css
/* Slow: layout every frame */
.card { transition: top 0.3s; }

/* Fast: compositor only */
.card { transition: transform 0.3s; }

Don't read layout inside scroll handlers

getBoundingClientRect() after a style change forces a synchronous layout. In scroll handlers, read once per frame inside requestAnimationFrame, then write:

ts
let ticking = false
window.addEventListener('scroll', () => {
  if (ticking) return
  ticking = true
  requestAnimationFrame(() => {
    const top = section.getBoundingClientRect().top   // read
    progressBar.style.transform = `scaleX(${clamp(-top / range)})` // write
    ticking = false
  })
}, { passive: true })

Better still, let a library like GSAP's ScrollTrigger batch this for you, or use CSS scroll-driven animations (animation-timeline: scroll()) where they're supported.

Stop work you can't see

Every requestAnimationFrame loop on the page runs on the main thread. Pause canvases, videos and loops when they leave the viewport, and when the tab is hidden:

ts
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()))
io.observe(canvas)
document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()))

Cap the canvas resolution

A WebGL canvas at devicePixelRatio 3 on a phone renders nine times the pixels of DPR 1. Cap it:

ts
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

For full-screen effects like fog or noise, rendering at half resolution and scaling up is often indistinguishable.

Break up long tasks

Building a scene, parsing a big JSON or preparing many text meshes can block the thread for hundreds of milliseconds. Split the work and yield between chunks (await scheduler.yield() where available, setTimeout(0) otherwise) so a tap during setup is answered quickly.

CLS: reserve space for everything

Give media dimensions

Every image, video, iframe and canvas needs width/height attributes or a CSS aspect-ratio so the browser reserves the space before it loads.

Load fonts without a jump

Display fonts are part of an animated site's character, and a late font swap shifts every line. Preload the one or two critical files, subset them, and use font-display: swap with a fallback that has adjusted metrics (size-adjust, ascent-override) — next/font generates this automatically.

Animations don't count — layout changes do

CLS ignores transform animations, but not changes to size or position via layout properties. A banner that pushes content down after load counts; the same banner sliding in with transform over reserved space doesn't.

Respect reduced motion

Some visitors ask their operating system for less motion. Honour it — it's an accessibility requirement, and those visitors also get a lighter page:

css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

In JavaScript, check matchMedia('(prefers-reduced-motion: reduce)') before starting loops, parallax and auto-playing video.

A workflow that catches regressions

  1. Record a Performance trace in Chrome DevTools with 4× CPU throttling while scrolling the whole page. Look for long tasks and purple layout bars.
  2. Turn on Rendering → Paint flashing and Layout Shift Regions to see what repaints and moves.
  3. Check PageSpeed Insights field data weekly after launch; lab scores are a guide, field data is the grade.

Checklist

  • Hero content visible in the initial HTML, not faded in from zero
  • LCP image preloaded with fetchpriority="high"
  • Heavy libraries loaded on demand
  • Only transform and opacity animated
  • Layout reads batched in requestAnimationFrame
  • Off-screen loops, canvases and videos paused
  • Canvas pixel ratio capped
  • Dimensions on all media; fonts with metric-matched fallbacks
  • prefers-reduced-motion respected

For the animation side of this, see GSAP in React and Next.js.

Newsletter

Get new posts by email

A short email when something new is up. No spam, and you can unsubscribe at any time.

GSAP in React and Next.js: useGSAP, Cleanup and ScrollTrigger
GSAP in React and Next.js: useGSAP, Cleanup and ScrollTrigger
Next post →