Engineering

How to create fluid animation on the web

WebGL, WebGPU, Canvas 2D, SVG, CSS, Houdini — six ways to move pixels, with wildly different ceilings. Here is how to pick one, and the timing rules that decide whether your animation feels fluid regardless of which you picked.

Fluid is a budget, not a frame rate

On a 60 Hz display you get 16.7 ms per frame. On a 120 Hz phone, 8.3 ms. That budget covers everything the browser does: your JavaScript, style recalculation, layout, paint, compositing, and the GPU's own work.

Two things follow, and both are counterintuitive.

First, consistency beats peak throughput. A steady 30 fps reads as smooth. A stream of 60 fps frames with a 120 ms hitch every second reads as broken. Users perceive the worst frames, not the average, which is why "average FPS" is close to a useless metric.

Second, the budget is not yours alone. Your animation shares it with the page's other work. An animation that costs 8 ms is fine on an idle page and catastrophic on a page that is also hydrating a component tree.

So the real question is not "which technology is fastest," it is "which technology has a cost model that stays inside the budget as my scene grows." That is what the table below is actually comparing.

The comparison table

Practical ceilings for smooth, continuous animation on mid-range hardware. Treat the object counts as order-of-magnitude guidance, not guarantees.

Approach Practical ceiling Cost scales with Main thread Text & a11y Effort Best for
CSS transitions / animations ~10² elements Element count, layer count Off-thread if compositor-only Native Minimal UI motion, transitions, micro-interactions
Web Animations API ~10² elements Element count Off-thread if compositor-only Native Low CSS-grade motion that needs JS control
SVG (DOM) ~10² shapes Node count, path complexity Yes — full paint Native Low Resolution-independent vector art, icons, charts
Canvas 2D ~10³–10⁴ sprites Draw calls × pixels covered Yes, unless Offscreen Manual Moderate Particles, 2D games, data-heavy charts
WebGL / WebGL2 10⁵–10⁶+ / full-screen shaders Draw calls, fragments, state changes Minimal — GPU does the work Manual High Per-pixel effects, 3D, large particle systems
WebGPU Higher than WebGL Same, plus compute Minimal Manual Highest Compute-driven simulation, heavy 3D
Houdini Paint API ~10² painted elements Painted area Off-thread paint worklet Native Moderate Custom backgrounds and borders that animate
Video / Lottie Any complexity Decode + composite Mostly off-thread Partial Low Pre-authored motion that never reacts to input

Read the table by column, not by row. "Cost scales with" is the column that predicts whether you will hit a wall. CSS scales with element count; Canvas scales with draw calls; WebGL scales with fragments. If the thing your scene has a lot of is the thing in that column, you have chosen wrong.

CSS and the DOM

The browser can animate a handful of properties entirely on the compositor thread, without running JavaScript, without recalculating layout, and without repainting. In practice that list is:

  • transform (translate, scale, rotate, skew)
  • opacity
  • filter — usually, though it is GPU work

Animating those is close to free and keeps running even when the main thread is busy. Animating anything else — width, top, margin, box-shadow — forces layout or paint on every frame, on the main thread.

/* Compositor-only: cheap, runs off the main thread */
.card { transition: transform 240ms ease, opacity 240ms ease; }
.card:hover { transform: translateY(-4px) scale(1.02); }

/* Forces layout every frame: avoid */
.card-bad { transition: top 240ms ease, width 240ms ease; }

The other classic DOM killer is layout thrashing — reading a geometry property after writing one, in a loop. Each read forces the browser to flush pending style and layout work synchronously:

// Bad — a forced synchronous layout per iteration
for (const el of items) {
  el.style.transform = `translateY(${el.offsetTop * 0.1}px)`;
}

// Good — batch all reads, then all writes
const offsets = items.map((el) => el.offsetTop);
for (const [index, el] of items.entries()) {
  el.style.transform = `translateY(${offsets[index] * 0.1}px)`;
}

Use will-change: transform sparingly to promote an element to its own layer before animating it. Each promoted layer costs GPU memory, and promoting hundreds of elements is slower than promoting none.

Verdict: for UI motion, CSS is not the compromise choice — it is the correct one. It is accessible by default, respects user settings, survives a busy main thread, and requires no frame loop.

SVG

SVG gives you resolution independence and real DOM nodes you can style, script, and expose to assistive technology. The cost is that every shape is a DOM node, so you pay style and layout costs per shape, and paint is rasterised on the main thread.

SVG is excellent at a few hundred animated shapes and falls apart at a few thousand — the failure is gradual and shows up as rising main-thread time rather than an obvious cliff. Path morphing and animated filters (feGaussianBlur and friends) are especially expensive, because filters re-rasterise the affected region every frame.

Animate SVG with CSS transform where possible, for the same compositor reasons as regular DOM. Animating d, cx, or filter parameters means a full repaint each frame.

Canvas 2D

Canvas 2D is an immediate-mode API: you clear and redraw the whole scene every frame. No DOM nodes, no per-object layout, no style recalculation. That removes the DOM's scaling problem and replaces it with a different one — every pixel you touch costs, and you own everything the DOM used to do for you (hit testing, accessibility, text layout).

const canvas = document.querySelector('#scene');
const ctx = canvas.getContext('2d', { alpha: false });   // opaque is faster

let last = performance.now();
function frame(now) {
  requestAnimationFrame(frame);
  const dt = Math.min(0.05, (now - last) / 1000);        // clamp after a tab switch
  last = now;

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (const p of particles) {
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    ctx.fillRect(p.x, p.y, 2, 2);
  }
}
requestAnimationFrame(frame);

The practical optimisations, roughly in order of impact:

  • Batch by state. Changing fillStyle, font, or globalAlpha is expensive. Sort your draws so you set each once.
  • Cache to offscreen canvases. Anything drawn identically more than once — a sprite, a glow, a gradient — should be rendered once into its own canvas and blitted with drawImage.
  • Redraw only dirty regions when the scene is mostly static.
  • Use alpha: false when you do not need transparency; it lets the compositor skip blending.
  • Move it off the main thread with OffscreenCanvas in a worker (see below).

Verdict: the pragmatic middle. Thousands of simple objects, a familiar API, and no shader knowledge required. It runs out of room when you need per-pixel effects across a full screen, because that is fragment work and the CPU is the wrong processor for it.

WebGL

WebGL hands the work to the GPU. The mental shift is that you stop thinking about objects and start thinking about two programs: a vertex shader that positions geometry, and a fragment shader that runs once per pixel, massively in parallel.

That parallelism is why a full-screen effect costs roughly the same whether it is a simple gradient or a raymarched tunnel — you are paying for pixels, not for scene complexity. Vibro Music renders its entire visualizer as two triangles covering the screen, with all the visual complexity living in the fragment shader. There is no geometry to speak of.

The one number that dominates full-screen shader performance is how many fragments you ask for, and that is set by the canvas resolution. On a 3× device pixel ratio phone, a "full screen" canvas is nine times the pixels of a 1× one. Capping the DPR is the single highest-leverage optimisation available:

function resizeCanvas() {
  const dpr = Math.min(window.devicePixelRatio || 1, 1.5);  // cap, do not trust the device
  const width = Math.max(1, Math.floor(innerWidth * dpr));
  const height = Math.max(1, Math.floor(innerHeight * dpr));
  if (canvas.width !== width || canvas.height !== height) {
    canvas.width = width;
    canvas.height = height;
    gl.viewport(0, 0, width, height);
  }
}

A cap of 1.5 instead of 3 cuts fragment work by a factor of four, and on a moving, high-contrast visual essentially nobody can see the difference. Note also that the resize is guarded by an equality check — reallocating the drawing buffer every frame is a real and commonly shipped bug.

Other things that matter more than they look:

  • Compile shaders once. Shader compilation and linking can take tens of milliseconds. Never do it inside the frame loop.
  • Cache uniform locations. getUniformLocation is a string lookup; resolve them all at link time.
  • Prefer texSubImage2D to texImage2D for per-frame texture updates — it reuses the existing allocation instead of making a new one.
  • Handle context loss. Mobile browsers discard WebGL contexts under memory pressure. If you do not listen for webglcontextlost, your animation dies silently.
  • Have a fallback. WebGL2 is widely available but not universal, and shader compilation can fail on unusual drivers. Detect and degrade.

Verdict: the only realistic option for per-pixel effects, and the highest effort by a wide margin. Choose it when the effect is the product.

WebGPU

WebGPU is the successor API: an explicit pipeline model, much cheaper draw call submission, and — the real headline — compute shaders. Particle simulation, fluid dynamics, and physics can run entirely on the GPU without round-tripping through JavaScript.

The trade is availability and complexity. Browser support is good on desktop and improving on mobile, but it is still the API where you write a hundred lines to draw a triangle. In 2026 the sensible pattern for production is WebGPU with a WebGL fallback — which means writing the effect twice, in WGSL and GLSL.

Verdict: reach for it when you need GPU compute, or when draw call count is your bottleneck. For a full-screen fragment effect, WebGL2 does the same job with far less ceremony and broader reach.

Houdini Paint API and other ideas

The CSS Paint API lets you register a paint worklet and use it as a CSS image value — background: paint(myThing). The worklet runs off the main thread, and you can drive it with animatable custom properties registered through CSS.registerProperty. It is a genuinely good fit for animated backgrounds, borders, and decorative fills that would otherwise need an absolutely positioned canvas. Support is not universal, so treat it as progressive enhancement behind a plain gradient.

Three more options worth knowing:

  • OffscreenCanvas in a worker. Not a rendering technology but a placement decision — it moves Canvas 2D or WebGL work off the main thread entirely, so your animation keeps its frame rate while the main thread is busy. This is often a bigger win than any micro-optimisation.
  • Scroll-driven animations. animation-timeline: scroll() and view() replace the classic scroll-listener-plus-rAF pattern with something that runs on the compositor. If you are animating on scroll, this is a large free win.
  • Pre-rendered video or Lottie. If the motion never reacts to input, a video decoder is extraordinarily efficient hardware that you are otherwise leaving idle. The moment it needs to react to anything, this option disappears.
// Hand a canvas to a worker and never think about main-thread jank again
const offscreen = document.querySelector('#scene').transferControlToOffscreen();
const worker = new Worker('./render-worker.js', { type: 'module' });
worker.postMessage({ canvas: offscreen }, [offscreen]);

Rules that apply to all of them

1. Always animate against elapsed time

If you advance state by a fixed amount per frame, your animation runs twice as fast on a 120 Hz display and crawls when a frame is dropped. Measure the delta, and clamp it — after a background tab or a garbage collection pause, now - last can be seconds, which teleports everything:

const deltaSeconds = Math.min(0.05, Math.max(0, (now - lastRenderAt) / 1000));
lastRenderAt = now;
travel += deltaSeconds * speed;

2. Smooth values, and know that naive lerp is frame-rate dependent

Exponential smoothing — value += (target - value) * 0.1 — is the workhorse of fluid motion. It is also subtly wrong: applied per frame, it converges twice as fast at 120 Hz as at 60 Hz. For UI polish nobody notices. For anything that must look identical across devices, correct for the timestep:

// Frame-rate dependent (fine for most UI)
function lerp(a, b, amount) {
  return a + (b - a) * amount;
}

// Frame-rate independent: halfLife is the time to close half the gap
function smoothTowards(current, target, halfLife, deltaSeconds) {
  const factor = 1 - Math.pow(0.5, deltaSeconds / halfLife);
  return current + (target - current) * factor;
}

A related trick worth stealing: use asymmetric smoothing when a value should react fast and settle slow. Vibro Music does this for every audio band, so the visuals snap to a beat but do not flicker as it decays:

function smoothFeature(previous, next, attack = 0.46, release = 0.18) {
  return lerp(previous, next, next > previous ? attack : release);
}

3. Schedule the callback before you do the work

Call requestAnimationFrame at the top of your frame function, not the bottom. If the body throws, the loop survives — otherwise a single exception ends your animation permanently.

const render = (now) => {
  requestAnimationFrame(render);              // first, always
  if (paused || stopped) return;              // cheap early-outs after
  update(now);
  draw(now);
};
requestAnimationFrame(render);

4. Respect prefers-reduced-motion

Vestibular disorders are real and large-amplitude motion triggers them. This is not optional polish. Detect the preference, react to changes at runtime, and scale motion down rather than removing feedback entirely:

const query = window.matchMedia('(prefers-reduced-motion: reduce)');
let reducedMotion = query.matches;
query.addEventListener('change', (event) => { reducedMotion = event.matches; });

const motionScale = reducedMotion ? 0.18 : 1;
travel += deltaSeconds * motionScale * speed;

The same reasoning covers flashing. Rapid full-screen luminance changes can trigger photosensitive seizures; if your effect can strobe, clamp it, and default the clamp to on.

5. Stop when nobody is looking

requestAnimationFrame pauses in background tabs, but a visible-but-offscreen element does not. Use an IntersectionObserver to stop animating what has scrolled out of view, and release audio and media resources on visibilitychange. Battery is a performance metric.

A decision procedure

In order, stop at the first yes:

  1. Is it UI motion — a transition, a hover, a reveal? Use CSS transitions or the Web Animations API. Do not build a frame loop.
  2. Is it driven by scroll position? Use scroll-driven animations. Do not write a scroll listener.
  3. Is it pre-authored motion that never reacts to anything? Use video or Lottie.
  4. Is it under a few hundred vector shapes that need to be accessible or crisp at any zoom? Use SVG.
  5. Are there thousands of simple objects, or does it need custom hit testing? Use Canvas 2D — in a worker via OffscreenCanvas if the main thread is busy.
  6. Is the effect per-pixel, full-screen, or genuinely 3D? Use WebGL2.
  7. Do you need GPU compute, or is draw call submission your bottleneck? Use WebGPU, with a WebGL fallback.

The most common mistake is jumping to step 6 because it sounds impressive. The second most common is staying at step 1 while animating a thousand elements and wondering why it stutters.

Measuring properly

Guessing is expensive. Three habits catch nearly everything:

  • Record a performance profile, not an FPS counter. The flame chart tells you which phase is over budget — scripting, style, layout, paint, or composite. An FPS number tells you only that something is.
  • Throttle the CPU 4–6× in DevTools. Your laptop is not your median user's phone. A 4× throttle is a reasonable proxy for a mid-range Android device.
  • Watch long tasks in the field. A PerformanceObserver on longtask entries surfaces the real-world hitches that never reproduce locally.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) console.warn('long task', entry.duration.toFixed(1), 'ms');
  }
}).observe({ entryTypes: ['longtask'] });

And when you profile a GPU-bound effect, remember the browser's timeline shows you the submission, not the GPU's execution. If scripting is near zero and frames are still late, you are fragment-bound — go back and cap that device pixel ratio.

The short version. Pick the cheapest technology whose cost model matches the shape of your scene, then spend your effort on timing rather than tricks. Delta time, clamped. Smoothing, corrected for the timestep. The frame request, scheduled first. Reduced motion, respected. Those four things separate animation that feels fluid from animation that merely runs.