Guides · 6 min read · Updated
How do you make an animated website fast on phones?
Set a budget before you build, serve small compressed media, start heavy effects after the first paint, and measure frame time at runtime so quality drops on slow devices instead of the whole page stuttering. Give visitors without a real GPU or with reduced motion turned on a still, readable page, and track Largest Contentful Paint, Interaction to Next Paint and Cumulative Layout Shift on real phones.
Start with a budget
A budget turns taste into a number you can fail a build on. The figures below are starting points we suggest for a marketing page with a hero effect. They are not a standard, so tune them to your audience and measure.
| Item | Suggested budget | Why |
|---|---|---|
| Hero poster image | Under 150 KB | It is usually the Largest Contentful Paint element |
| JavaScript before first interaction | Under 200 KB compressed | Parse and run time costs more on a phone than on your laptop |
| Hero video (if any) | Under 3 MB for phones | Mobile data and decode time |
| 3D model | Under 2 MB | Download plus GPU upload |
| Frame time | Under 16 ms at 60 Hz, with headroom | Dropped frames are what visitors feel |
| Effects running at once | One main effect, a few small ones | Each extra effect competes for the same GPU |
Every AnimatedWebs template card shows the live demo's page weight, so you can compare a light CSS-and-canvas page with a heavy video one before you pick. A lighter template is the cheapest optimization there is.
Compress media for the phone, not the studio
Media is almost always the largest part of an animated page. Export images in a modern format at the size they display, and make a separate smaller video for small screens.
# Poster: WebP at quality 72, a 1280 wide one and a 700 wide one for phones
cwebp -q 72 -resize 1280 0 hero.png -o hero.webp
cwebp -q 72 -resize 700 0 hero.png -o hero-720.webp
# Phone video: 720p, no audio, stronger compression, index first
ffmpeg -i hero-source.mov -an -vf "scale=1280:-2,fps=30" -c:v libx264 -preset slow -crf 28 -pix_fmt yuv420p -movflags +faststart hero-720.mp4<picture>
<source srcset="/hero-720.webp" media="(max-width: 700px)" type="image/webp" />
<img src="/hero.webp" alt="" width="1280" height="720"
fetchpriority="high" decoding="async" />
</picture>Set width and height on images and reserve space for the canvas, so nothing shifts when they load. Use fetchpriority="high" on the one image that is your Largest Contentful Paint element, and never lazy-load it. For scrubbed video, the video hero guide covers keyframes and faststart in detail.
Paint first, then start the effect
Visitors judge speed by when they see something useful. Render the headline and poster as plain HTML, then load the heavy library after the first paint with a dynamic import. The canvas fades in over the poster when it is ready.
const start = async () => {
const { initHero } = await import("./hero-webgl.js"); // loads three.js here
await initHero(document.getElementById("bg"));
document.documentElement.classList.add("hero-ready"); // CSS fades the canvas in
};
if ("requestIdleCallback" in window) {
requestIdleCallback(start, { timeout: 1500 });
} else {
setTimeout(start, 200);
}The timeout means the effect still starts on a busy device. Keep the poster visible until hero-ready, and make sure the poster looks like the first frame of the effect so the swap is not noticeable.
Adapt quality from measured frame time
You cannot know a visitor's phone in advance, but you can watch how it performs. Measure the time between frames, and when the average is bad for a while, lower the cost. Pixel ratio is the best first lever because shading cost grows with its square. The callback you pass to requestAnimationFrame receives a timestamp you can use for this.
const steps = [2, 1.5, 1.25, 1]; // pixel ratio ladder, best first
let level = steps.findIndex((r) => r <= devicePixelRatio);
if (level < 0) level = steps.length - 1;
let last = performance.now();
let sum = 0;
let n = 0;
export function onFrame(now, renderer) {
sum += now - last;
last = now;
n++;
if (n < 45) return;
const avg = sum / n;
sum = 0;
n = 0;
if (avg > 22 && level < steps.length - 1) {
level++;
renderer.setPixelRatio(steps[level]);
renderer.setSize(innerWidth, innerHeight, false);
}
}Only step down, never back up. Stepping up when things look good makes the page oscillate, which looks worse than a stable lower quality. After the lowest step, turn off the most expensive extras: bloom, shadows and extra particles. The 22 millisecond threshold is a starting point that tolerates a small amount of variation around 60 Hz; tune it on real hardware.
Software GL and weak GPUs
Some browsers report WebGL but draw it on the CPU with a software renderer. That happens in virtual machines, remote desktops, some older devices and with blocked graphics drivers, and it makes a heavy scene unusable. The context attribute failIfMajorPerformanceCaveat asks the browser to refuse to create a WebGL context when it would fall back to a slow renderer, so you can show a still image instead.
export function canRunWebGL() {
const probe = document.createElement("canvas");
const gl = probe.getContext("webgl2", { failIfMajorPerformanceCaveat: true })
|| probe.getContext("webgl", { failIfMajorPerformanceCaveat: true });
if (!gl) return false;
gl.getExtension("WEBGL_lose_context")?.loseContext();
return true;
}
if (!canRunWebGL()) {
document.documentElement.classList.add("no-gl"); // CSS shows the poster only
}The fallback should be a good page, not an apology: the poster, the full text and every button. If you construct your renderer with the same attribute, a refusal can also be caught there. Treat a missing context as a normal state, since tabs can also lose a context while open.
Reduced motion and data saving
Honor prefers-reduced-motion: reduce by replacing movement with a still frame or a short fade. That is both an accessibility need and a performance win, because the visitor no longer pays for the effect. Treat data saving the same way when the browser exposes it.
import { canRunWebGL } from "./gl-check.js";
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const saveData = navigator.connection && navigator.connection.saveData;
const lowCores = navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4;
export const richMotion = !reduce && !saveData && canRunWebGL();
// lowCores is a hint, not a verdict: use it to start at a lower pixel ratio.
export const startRatio = lowCores ? 1.25 : 2;Core Web Vitals on an animated page
Core Web Vitals are three field metrics: Largest Contentful Paint for loading, Interaction to Next Paint for responsiveness and Cumulative Layout Shift for visual stability. Good values are 2.5 seconds or less, 200 milliseconds or less and 0.1 or less, judged at the 75th percentile of real page loads, split by mobile and desktop.
| Metric | Animated page risk | Fix |
|---|---|---|
| Largest Contentful Paint | The hero is a canvas or a video that loads late | Make the LCP element a real image or heading, load the effect after it, preload the poster |
| Interaction to Next Paint | A render loop and big scripts block the main thread | Defer the library, keep per-frame work small, pause offscreen, avoid long tasks |
| Cumulative Layout Shift | Canvas, video or fonts change size after load | Give the canvas fixed or aspect-ratio size, set image dimensions, reserve space |
Lab tools are useful while building, but they run on your computer and network. Check the field data for your real visitors, and test on at least one modest Android phone and one older iPhone. Browser developer tools can throttle the CPU to approximate a slower device.
Keep the main thread free
Phones have slower processors than laptops, so JavaScript that feels instant on your machine can block a phone for a long time. Interaction to Next Paint reflects this: a tap that waits behind a long task feels broken. The fixes are plain. Ship less script, split code so the effect loads separately from the page, and move work out of the render loop.
- Allocate once. Create vectors, geometry and arrays outside the loop and reuse them, so the garbage collector has nothing to pause for.
- Throttle pointer work. A pointermove handler that does heavy math on every event should store the position and let the render loop read it.
- Use passive scroll listeners and keep them tiny. Store a number, do the work in
requestAnimationFrame. - Break long tasks up. If a setup step takes more than about 50 milliseconds, split it across frames or idle callbacks.
These habits also help battery life, which matters to visitors who never mention it but leave when their phone gets warm.
Test the way your visitors browse
Your development machine hides most problems. Open the page on a phone you do not use every day, on mobile data, with battery saver on. Watch the first screen load, scroll the whole page, rotate the device and switch tabs and come back. Each of those exposes a different failure: slow first paint, dropped frames, a broken resize and a stopped loop.
Then compare lab numbers with field data. A lab run shows what one device did once. Field data from real visits shows the spread, and the slowest quarter of your visitors is the group the 75th percentile rule is built to protect.
A pre-launch checklist
- 1Write the budget down, and fail the build when a file passes it.
- 2Make the first screen a real image and heading that works with no JavaScript.
- 3Load the effect after first paint, and fade it in over the poster.
- 4Add a pixel ratio ladder and step down from measured frame time.
- 5Check for software GL and show the still version when it applies.
- 6Honor reduced motion and data saving.
- 7Pause the loop when the tab is hidden or the canvas is offscreen.
- 8Test on real phones, then read the field metrics a week after launch.





