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:
<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:
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:
/* 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:
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:
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:
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:
@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
- Record a Performance trace in Chrome DevTools with 4× CPU throttling while scrolling the whole page. Look for long tasks and purple layout bars.
- Turn on Rendering → Paint flashing and Layout Shift Regions to see what repaints and moves.
- 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
transformandopacityanimated - 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-motionrespected
For the animation side of this, see GSAP in React and Next.js.

