Brad Holmes web developer, designer and digital strategist.

Dad, husband and dog owner. Most days I’m trying to create fast, search-friendly websites that balance UX, Core Web Vitals, and digital strategy from my studio in Kettering, UK.

If you’re here, you either found something I built on Google or you’re just being nosey. Either way, this is me, the work, the thinking, and the bits in between.

Brought to you by Brad Holmes

Brad Holmes Web Developer

How to Build Scroll Animations That Don’t Kill Performance

Brad Holmes By Brad Holmes
11 min read

Scroll animation still breaks more than it works. Most developers focus on effects instead of the system behind them. They chain transforms, trigger repaints, and throw easing curves at the problem until the browser chokes. The result looks smooth on a dev machine but dies on mobile.

The real issue isn’t scroll animation itself. It’s what you’re animating.

Every time the browser has to touch the DOM recalculate layout, restyle, repaint you’re asking the CPU to do what the GPU was built for. The heavier the page, the slower the reaction, and the further you drift from that 60fps target.

<canvas> fixes that by skipping the DOM entirely. It draws pixels directly to the screen, which means the browser doesn’t need to recalc anything between frames. You control every render, every pixel, and every frame. The browser just composites what you give it.

That shift from manipulating elements to painting pixels is where smoothness starts to feel effortless. Canvas isn’t a trick for more power. It’s a way to stop wasting it.

Read Why Most Scroll Animations Miss What Apple Gets Right.

Map Scroll to Canvas Draws

The core of any scroll animation is a single relationship: scroll position mapped to visual change. In the DOM, that usually means adjusting CSS transforms or opacity. In <canvas>, it means redrawing pixels based on progress.

The key difference is control. Instead of telling the browser what to animate, you decide exactly what gets drawn each frame. No layout recalculations, no CSS transitions, just pixels.

Start with something simple. A circle that moves horizontally as the user scrolls.

Create a full-screen canvas and a short script to map scroll progress to drawing position:

<canvas id="scrollCanvas"></canvas>

<script>
const canvas = document.getElementById('scrollCanvas');
const ctx = canvas.getContext('2d');

// Resize canvas to fill viewport
function resize() {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();

function draw(progress) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  const x = progress * canvas.width;
  const y = canvas.height / 2;

  ctx.beginPath();
  ctx.arc(x, y, 40, 0, 2 * Math.PI);
  ctx.fillStyle = '#00b4ff';
  ctx.fill();
}

// Track scroll and draw
window.addEventListener('scroll', () => {
  const scrollTop = window.scrollY;
  const maxScroll = document.body.scrollHeight - window.innerHeight;
  const progress = scrollTop / maxScroll;

  draw(progress);
});
</script>

Scroll down the page, and the circle moves smoothly across the canvas. There’s no CSS, no transforms just a direct relationship between scroll position and pixels drawn.

This looks simple, but it’s the cleanest mental model for performant motion:

  • Scroll input defines state (how far along the animation is).
  • Canvas output renders that state visually.

You’re separating logic from rendering, which is the first step toward reliable 60fps motion.

Run Motion in a Render Loop, Not a Scroll Event

The scroll event fires more than you think. On fast hardware it can trigger dozens of times per frame, and on slower devices it can pile up so badly that your animation never catches up.
You can’t make smooth motion if your code depends on when scroll events happen to fire.

The fix is simple: read scroll position when it changes, but render frames independently. The browser already gives you the perfect tool for this requestAnimationFrame().

Here’s how to restructure your code:

<canvas id="scrollCanvas"></canvas>

<script>
const canvas = document.getElementById('scrollCanvas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

let scrollPos = 0; // current scroll position
let progress = 0;  // normalized 0–1 scroll value

window.addEventListener('scroll', () => {
  scrollPos = window.scrollY;
});

function draw(progress) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  const x = progress * canvas.width;
  const y = canvas.height / 2;

  ctx.beginPath();
  ctx.arc(x, y, 40, 0, 2 * Math.PI);
  ctx.fillStyle = '#00b4ff';
  ctx.fill();
}

function render() {
  const maxScroll = document.body.scrollHeight - window.innerHeight;
  progress = scrollPos / maxScroll;
  draw(progress);
  requestAnimationFrame(render);
}

render();
</script>

Now the animation runs in a continuous loop. The scroll handler only updates a number; it doesn’t trigger paint or layout work. The render loop handles the drawing at a steady 60 frames per second.

This single change usually cuts main-thread load by half.
The browser decides when to paint, and your code simply provides the frame data.

If you profile this in Chrome’s Performance panel, you’ll notice:

  • No dropped frames unless the canvas draw itself is heavy.
  • No spikes in scripting time from scroll floods.
  • Predictable motion timing across devices.

Once you understand this pattern, every other optimization builds on top of it.
Scroll defines intent. The render loop defines execution.

Scroll-Scrubbed Video on Canvas

Frame-by-frame scroll effects used to mean preloading hundreds of images. It worked, but it was brutal on memory and network performance. Modern browsers make that pointless.
A single compressed video can deliver the same visual fidelity at a fraction of the cost especially when you render it on <canvas>.

You don’t need a playback controller or complex library. You just need to map scroll progress to the video’s playback time and let canvas handle the pixels.

Here’s the simplest version:

<video id="scrollVideo" src="your-video.mp4" preload muted playsinline></video>
<canvas id="videoCanvas"></canvas>

<script>
const video = document.getElementById('scrollVideo');
const canvas = document.getElementById('videoCanvas');
const ctx = canvas.getContext('2d');

canvas.width = innerWidth;
canvas.height = innerHeight;

let scrollPos = 0;
window.addEventListener('scroll', () => {
  scrollPos = window.scrollY;
});

video.addEventListener('loadeddata', () => {
  requestAnimationFrame(render);
});

function render() {
  const maxScroll = document.body.scrollHeight - window.innerHeight;
  const progress = scrollPos / maxScroll;
  video.currentTime = progress * video.duration;

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

  requestAnimationFrame(render);
}
</script>

The scroll position controls video.currentTime, which determines which frame is drawn to the canvas.
Because the canvas draw happens in requestAnimationFrame, you get consistent motion without scroll jank or desync.

For even better performance:

  • Keep the video short (under 5 seconds).
  • Compress with ffmpeg -crf 30 -preset veryfast.
  • Add a static poster frame so users see an image before buffering finishes.
  • Use playsinline to avoid mobile full-screen takeover.

This approach cuts load size dramatically compared to traditional frame stacks sometimes by 80 percent or more and gives you direct control over how each frame is rendered.
You can tint, scale, or blend frames before drawing them, which opens up endless room for subtle effects without touching the DOM.

Scroll once, and the video scrubs like film. Smooth, direct, and GPU-friendly.

Layered Motion and Pacing Control

The fastest way to make scroll animation feel unpolished is to let pacing depend on element height or asset size.

If one section scrolls slower because it’s taller, the experience breaks instantly. The motion no longer guides the user it fights them.

On <canvas>, you have no layout. That’s a gift. You define pacing entirely through math, not markup.

Instead of mapping animation to pixel distance, map it to a normalized range – 0 to 1 – that always represents full scroll progress. Whether the page is 2,000 pixels tall or 20,000, your motion stays perfectly in sync.

Once that’s in place, you can layer visuals for subtle parallax without ever touching the DOM.

Here’s an example:

<canvas id="parallaxCanvas"></canvas>

<script>
const canvas = document.getElementById('parallaxCanvas');
const ctx = canvas.getContext('2d');
canvas.width = innerWidth;
canvas.height = innerHeight;

let scrollPos = 0;
window.addEventListener('scroll', () => {
  scrollPos = window.scrollY;
});

const layers = [
  { color: '#0a84ff', speed: 0.2 },
  { color: '#5ac8fa', speed: 0.4 },
  { color: '#64d2ff', speed: 0.8 }
];

function render() {
  const maxScroll = document.body.scrollHeight - window.innerHeight;
  const progress = scrollPos / maxScroll;

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  layers.forEach((layer, i) => {
    const offset = progress * layer.speed * canvas.height * 2;
    ctx.fillStyle = layer.color;
    ctx.beginPath();
    ctx.arc(
      canvas.width / 2,
      canvas.height / 2 - offset,
      120 - i * 30,
      0,
      2 * Math.PI
    );
    ctx.fill();
  });

  requestAnimationFrame(render);
}

render();
</script>

Each layer moves at a different rate based on the same normalized scroll value.
There’s no dependency on DOM height, and motion always feels intentional smooth on every screen size.

You can expand this pattern endlessly:

  • Replace circles with images, SVG paths, or gradients.
  • Add easing functions to slow down or accelerate specific layers.
  • Blend shapes with globalAlpha for depth.

What matters is that timing stays consistent and predictable.
The scroll position doesn’t describe “where” something is it describes “how far along” the experience should be.

That mindset turns scroll from a trigger into a timeline.

Profile and Build Restraint Into Motion

Smooth animation isn’t luck. It’s proof of control.
Canvas gives you the tools to build that control, but it also gives you enough power to make a mess fast. The difference between premium motion and noise is restraint and data.

Profile First

Performance is visual, but it’s also measurable.
Open Chrome DevTools → Performance and record a scroll. Watch three things:

  • Frame time: every frame should land near 16 ms for 60fps.
  • Main thread load: anything heavy in scripting means your draw calls are doing too much.
  • GPU memory: sudden spikes mean too many layers or unthrottled texture uploads.

The moment you see dropped frames or inconsistent pacing, strip it back. Remove layers, simplify draws, test again. The goal isn’t more motion it’s cleaner motion.

Real users don’t test on your machine.
Profile on mid-range hardware, throttle the CPU, and scroll like a human, not a benchmark. If it still feels fluid, it’s ready.

Build Restraint Into the Workflow

Every animation should earn its place.
A quick checklist keeps you honest:

  • Does the motion reveal something static design can’t?
  • Can it be expressed in one draw function instead of a dozen?
  • Does it still feel smooth when CPU throttled to 4x?
  • Does it clarify interaction or just decorate it?

If any answer fails, delete it.

Motion only feels expensive when it’s deliberate. The best scroll sequences don’t show effort they show control.

The less you make the browser do, the more premium the result feels.

The Bottom Line

The smoothest scroll animations aren’t built with CSS or event listeners.
They’re painted directly, frame by frame, in <canvas>.

DOM animation fights layout and paint. Canvas skips both.
You decide what changes, when, and why. The browser simply renders your intent.

That’s the new standard:
GPU precision, scroll intent, and nothing wasted.

When motion feels effortless, you’ve done it right.

Brad Holmes

Brad Holmes

Web developer, designer and digital strategist.

Brad Holmes is a full-stack developer and designer based in the UK with over 20 years’ experience building websites and web apps. He’s worked with agencies, product teams, and clients directly to deliver everything from brand sites to complex systems—always with a focus on UX that makes sense, architecture that scales, and content strategies that actually convert.

Thanks Brad, I found this really helpful
TOP