👉 View and interact with the live demo on CodePen

GIF with animation

Let's face it—we've all visited websites with those fancy scroll animations that somehow manage to turn your laptop into a stuttering mess. You know the ones I'm talking about: you scroll down and everything freezes for a second before jumping to catch up, making you wonder if you should just close the tab and go back to Instagram.

But it doesn't have to be that way. I've spent more late nights than I care to admit optimizing scroll animations, and I'm here to share how to build that slick scroll-based canvas animation you've been eyeing—the kind with shapes floating around, responding to your every scroll—without melting anyone's device.

TL;DR:

Want to build a buttery-smooth scroll animation with shapes that move in a parallax effect? The secret is: 1) use canvas instead of DOM elements, 2) implement proper throttling, 3) batch updates with requestAnimationFrame, 4) optimize for different devices, and 5) respect user preferences. I'll walk you through building it from scratch with performance as the #1 priority.

👀 Related:

If you're into making your UI faster overall, not just prettier—check out my deep dive on 8 JavaScript Helper Functions That Make Your UI Feel Instant. I shared practical tools like debounce, throttle, virtual scrolling, and more.

The Animation We're Building

First, let's get clear on what we're creating: a scroll-based animation where colorful shapes converge toward the center as you scroll down, with a title that fades in and scales slightly. It's a simple but effective parallax animation that adds visual interest without being distracting.

Here's what makes our implementation special:

  • It runs at 60fps even on mid-range mobile devices
  • It automatically adapts to different screen sizes and pixel densities
  • It respects user preferences (like reduced motion)
  • It doesn't drain your battery like a 5-year-old phone running Fortnite

Performance Bottlenecks: Why Most Scroll Animations Suck

Before diving into how to build ours properly, let's understand why so many scroll animations turn into slideshow presentations:

  1. The DOM Is Not Your Friend - Each time you move 50+ DOM elements on scroll, you're begging for layout thrashing. The browser has to recalculate positions and repaint everything... repeatedly.

  2. Event Tsunami - Scroll events fire like a machine gun—easily 30+ times per second. Doing heavy lifting on each one is performance suicide.

  3. JavaScript Traffic Jams - Complex calculations on the main thread block everything else from happening. Users scroll, nothing moves, they scroll more, everything jumps. Rage ensues.

  4. Device Blindness - Developers with beefy machines often forget that most people aren't rocking the latest M2 MacBook Pro. What works on your dev machine might crawl on the average user's device.

  5. Battery Vampires - Poorly optimized animations don't just feel laggy—they actively drain batteries by constantly forcing the CPU and GPU to work overtime.

With those pitfalls in mind, let's build something that actually respects both users and their devices.

Setting Up Our HTML & CSS Foundation

First, the basic structure. Nothing fancy here, but pay attention to some of the CSS properties that help with performance:

</span>
 lang="en">

   charset="UTF-8">
   name="viewport" content="width=device-width, initial-scale=1.0">
  Smooth Scroll Animation
  
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    :root {
      --primary-color: #fff;
      --bg-color: #000;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
      overflow-x: hidden;
      background-color: var(--bg-color);
      color: var(--primary-color);
    }

    .scroll-container {
      position: relative;
      /* Height will be set dynamically with JS */
    }

    .sticky-container {
      position: sticky;
      top: 0;
      height: 100vh;
      width: 100%;
      overflow: hidden;
      display: flex;
      justify-content: center;
      align-items: center;
      contain: layout paint style; /* Performance boost */
    }

    .canvas-container {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      will-change: transform; /* Hint to browser for optimization */
    }

    canvas {
      position: absolute;
      top: 0;
      left: 0;
      backface-visibility: hidden;
      transform: translateZ(0); /* Force GPU acceleration */
    }

    .title {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-size: 10vw;
      font-weight: 300;
      letter-spacing: -0.03em;
      text-align: center;
      white-space: nowrap;
      color: var(--primary-color);
      z-index: 10;
      opacity: 0;
      mix-blend-mode: difference;
      text-transform: uppercase;
      will-change: opacity, transform; /* Performance hint */
    }

    .progress {
      position: fixed;
      bottom: 0;
      left: 0;
      width: 100%;
      height: 2px;
      background: rgba(255, 255, 255, 0.1);
      z-index: 100;
    }

    .progress-bar {
      height: 100%;
      width: 0;
      background: var(--primary-color);
      will-change: width;
    }
  


   class="progress"> class="progress-bar">
   class="scroll-container">
     class="sticky-container">
       class="title">REVELATION
       class="canvas-container">
         id="canvas">
      
    
  
  
    // Our JavaScript will go here
  





    Enter fullscreen mode
    


    Exit fullscreen mode
    




Notice those CSS properties like will-change, transform: translateZ(0), and contain? Those aren't just fancy buzzwords—they're direct signals to the browser about how to optimize rendering. We're essentially saying "Hey browser, these elements are going to change a lot, so prepare accordingly."
  
  
  The JavaScript Engine: Where the Real Magic Happens
Now for the JavaScript. I'll break this down into manageable chunks and explain why each optimization matters:
  
  
  1. Setting Up Our Foundation

(() => {
  // Core variables
  let canvas, ctx;
  let width, height;
  let shapes = [];
  let lastScrollY = 0;
  let maxScroll = 0;
  let needsUpdate = true;
  let animationFrameId = null;
  let isReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let isTabVisible = true;
  let shapeCache = null;
  let ticking = false;

  const scrollMultiplier = 5; // Controls scroll length
  const colors = ['#ff9500', '#ff2d55', '#5ac8fa', '#007aff', '#34c759', '#af52de'];

  // Debounce helper - essential for resize events
  function debounce(func, wait) {
    let timeout;
    return function() {
      const context = this, args = arguments;
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(context, args), wait);
    };
  }
})();



    Enter fullscreen mode
    


    Exit fullscreen mode
    




I'm wrapping everything in an IIFE (Immediately Invoked Function Expression) to avoid polluting the global scope. It's a small thing, but every bit of optimization counts when you're building something performance-critical.
  
  
  2. Setting Up the Canvas

function createCanvas() {
  canvas = document.getElementById('canvas');
  if (!canvas) return;

  // Performance boost with these context options
  ctx = canvas.getContext('2d', { 
    alpha: false,         // Don't need transparency = faster
    desynchronized: true, // Reduce latency
    colorSpace: 'srgb'    // Optimize color calculations
  });

  setScrollHeight();
  resizeCanvas();
}

function setScrollHeight() {
  const scrollContainer = document.querySelector('.scroll-container');
  if (scrollContainer) {
    const viewportHeight = window.innerHeight;
    // Make scroll area 5x viewport height
    scrollContainer.style.height = `${viewportHeight * scrollMultiplier}px`;
    maxScroll = viewportHeight * (scrollMultiplier - 1);
  }
}

function resizeCanvas() {
  // Handle retina displays properly, but limit scaling
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  width = window.innerWidth;
  height = window.innerHeight;

  canvas.width = width * dpr;
  canvas.height = height * dpr;
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;

  ctx.scale(dpr, dpr);

  needsUpdate = true;
}



    Enter fullscreen mode
    


    Exit fullscreen mode
    




The canvas setup is where many developers leave performance on the table. Look at those context options—they make a real difference:

alpha: false tells the browser we don't need transparency, which saves compositing time

desynchronized: true reduces input latency
Setting canvas dimensions for retina displays is crucial, but I cap the pixel ratio at 2x because going higher has diminishing returns while eating more resources
Also notice how we're setting the scroll container height dynamically based on viewport size. This ensures the animation feels consistent on all devices instead of being too short on large screens or too long on small ones.
  
  
  3. Creating the Shapes

function getOptimalShapeCount() {
  const screenSize = width * height;
  const isMobile = window.innerWidth <= 768;
  const isLowPower = isMobile && !matchMedia('(min-resolution: 2dppx)').matches;

  // Reduce density for low-power devices
  let density = Math.min(window.devicePixelRatio || 1, 2);
  if (isLowPower) density = 0.5;

  // Base count on screen size and adjust for reduced motion
  const baseCount = screenSize / (isReducedMotion ? 15000 : 10000);
  return Math.floor(baseCount * density);
}

function initShapes() {
  // Reuse shape cache if dimensions haven't changed
  if (shapeCache && width === shapeCache.width && height === shapeCache.height) {
    shapes = [...shapeCache.shapes];
    return;
  }

  shapes = [];

  // Calculate optimal shape count based on device capabilities
  const optimalCount = getOptimalShapeCount();
  const cellSize = Math.sqrt((width * height) / optimalCount);

  const cols = Math.floor(width / cellSize) + 1;
  const rows = Math.floor(height / cellSize) + 1;

  // Golden angle for visually pleasing distribution
  const goldenAngle = Math.PI * (3 - Math.sqrt(5));

  for (let i = 0; i < cols * rows; i++) {
    const col = i % cols;
    const row = Math.floor(i / cols);

    const x = (col / cols) * width * 1.1 - width * 0.05;
    const y = (row / rows) * height * 1.1 - height * 0.05;

    const shapeType = (col + row) % 5;
    const size = 30 + Math.random() * 50;
    const colorIndex = (col + row * 2) % colors.length;
    const color = colors[colorIndex];
    const rotation = i * goldenAngle;

    shapes.push({
      x,
      y,
      size,
      type: shapeType,
      color,
      rotation,
      originalX: x,
      originalY: y,
      delay: Math.random() * 0.3
    });
  }

  // Cache shapes for reuse
  shapeCache = {
    width,
    height,
    shapes: [...shapes]
  };
}



    Enter fullscreen mode
    


    Exit fullscreen mode
    




This is where the real performance magic starts. Instead of blindly generating thousands of shapes, we:
Calculate the optimal number based on screen size and device capabilities
Detect low-power devices and reduce density accordingly
Use caching to avoid regenerating shapes when not necessary
Arrange shapes using the golden angle for visual appeal without extra computation
This approach means a flagship phone might get 400 shapes while a budget phone gets 100—both looking good but running smoothly on their respective hardware.
  
  
  4. Drawing Optimizations

// Pre-compute shape drawing functions
const shapeFunctions = [
  // Square
  (ctx, size) => {
    ctx.fillRect(-size/2, -size/2, size, size);
  },
  // Circle
  (ctx, size) => {
    ctx.beginPath();
    ctx.arc(0, 0, size/2, 0, Math.PI * 2);
    ctx.fill();
  },
  // Triangle
  (ctx, size) => {
    ctx.beginPath();
    ctx.moveTo(0, -size/2);
    ctx.lineTo(size/2, size/2);
    ctx.lineTo(-size/2, size/2);
    ctx.closePath();
    ctx.fill();
  },
  // Cross
  (ctx, size) => {
    const barWidth = size / 5;
    ctx.fillRect(-size/2, -barWidth/2, size, barWidth);
    ctx.fillRect(-barWidth/2, -size/2, barWidth, size);
  },
  // Diamond
  (ctx, size) => {
    ctx.beginPath();
    ctx.moveTo(0, -size/2);
    ctx.lineTo(size/2, 0);
    ctx.lineTo(0, size/2);
    ctx.lineTo(-size/2, 0);
    ctx.closePath();
    ctx.fill();
  }
];

function drawShape(shape, scrollProgress) {
  const { x, y, size, type, color, rotation } = shape;

  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(rotation + scrollProgress * Math.PI);

  const fadePoint = shape.delay;
  const opacity = scrollProgress <= fadePoint ? 0 : 
                  scrollProgress > fadePoint + 0.3 ? 1 : 
                  (scrollProgress - fadePoint) / 0.3;

  ctx.globalAlpha = opacity;
  ctx.fillStyle = color;

  // Use pre-computed drawing function
  if (shapeFunctions[type]) {
    shapeFunctions[type](ctx, size);
  }

  ctx.restore();
}



    Enter fullscreen mode
    


    Exit fullscreen mode
    




Using an array of functions instead of a switch statement might seem like a small thing, but it actually matters. It's faster to look up an array index than to evaluate multiple conditions, especially when drawing hundreds of shapes dozens of times per second.
  
  
  5. The Animation Loop

function updateAndDraw(timestamp) {
  animationFrameId = null;

  // Don't waste cycles when tab isn't visible
  if (!isTabVisible) {
    return;
  }

  // Only render when something changed
  if (!needsUpdate) {
    animationFrameId = requestAnimationFrame(updateAndDraw);
    return;
  }

  needsUpdate = false;

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

  const scrollProgress = Math.min(Math.max(lastScrollY / maxScroll, 0), 1);

  // Update progress bar
  const progressBar = document.querySelector('.progress-bar');
  if (progressBar) {
    progressBar.style.width = `${scrollProgress * 100}%`;
  }

  // Update title opacity and scale
  const title = document.querySelector('.title');
  if (title) {
    title.style.opacity = scrollProgress > 0.2 ? 1 : scrollProgress / 0.2;
    const titleScale = 0.8 + scrollProgress * 0.4;
    title.style.transform = `translate(-50%, -50%) scale(${titleScale})`;
  }

  // Limit shapes based on device capability and user preferences
  const visibleShapesCount = isReducedMotion ? Math.floor(shapes.length / 2) : shapes.length;
  const optimizedCount = Math.min(visibleShapesCount, 300); // Hard cap for performance
  const shapesToRender = shapes.slice(0, optimizedCount);

  // Render shapes with parallax movement
  for (let i = 0; i < shapesToRender.length; i++) {
    const shape = shapesToRender[i];

    // Calculate parallax movement
    const moveX = (shape.originalX - width/2) * scrollProgress * 1.5;
    const moveY = (shape.originalY - height/2) * scrollProgress * 1.5;

    shape.x = shape.originalX + moveX;
    shape.y = shape.originalY + moveY;

    drawShape(shape, scrollProgress);
  }

  animationFrameId = requestAnimationFrame(updateAndDraw);
}



    Enter fullscreen mode
    


    Exit fullscreen mode
    




Here's where a lot of performance magic happens:
We skip rendering completely when the tab isn't visible
We only redraw when something has actually changed
We limit the number of shapes to at most 300 (or fewer on lower-end devices)
For users with reduced motion preferences, we cut the number of shapes in half
We calculate all positions once per frame instead of multiple times

  
  
  6. Event Handling and Throttling

function handleScroll() {
  lastScrollY = window.scrollY;

  // Throttle updates using requestAnimationFrame
  if (!ticking) {
    requestAnimationFrame(() => {
      needsUpdate = true;

      if (!animationFrameId && isTabVisible) {
        animationFrameId = requestAnimationFrame(updateAndDraw);
      }

      ticking = false;
    });

    ticking = true;
  }
}

const handleVisibilityChange = debounce(() => {
  isTabVisible = document.visibilityState === 'visible';

  if (isTabVisible && !animationFrameId) {
    needsUpdate = true;
    animationFrameId = requestAnimationFrame(updateAndDraw);
  }
}, 50);

const handleResize = debounce(() => {
  setScrollHeight();
  resizeCanvas();
  initShapes();
  needsUpdate = true;

  if (!animationFrameId && isTabVisible) {
    animationFrameId = requestAnimationFrame(updateAndDraw);
  }
}, 200);



    Enter fullscreen mode
    


    Exit fullscreen mode
    




This is probably the most critical part of making scroll animations feel smooth. Proper throttling and event handling can be the difference between butter-smooth scrolling and a janky mess:
We're using requestAnimationFrame for throttling scroll events (more efficient than time-based throttling)
Resize events are debounced with a 200ms delay (crucial since resize can fire hundreds of times during a single user action)
We track tab visibility to avoid wasting resources when the user isn't even looking at our animation

  
  
  7. Initialization and Cleanup

function cleanup() {
  window.removeEventListener('scroll', handleScroll);
  window.removeEventListener('resize', handleResize);
  document.removeEventListener('visibilitychange', handleVisibilityChange);

  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
    animationFrameId = null;
  }

  shapeCache = null;
}

function init() {
  createCanvas();
  initShapes();

  window.addEventListener('scroll', handleScroll, { passive: true });
  window.addEventListener('resize', handleResize, { passive: true });
  document.addEventListener('visibilitychange', handleVisibilityChange);

  if (isTabVisible) {
    needsUpdate = true;
    animationFrameId = requestAnimationFrame(updateAndDraw);
  }

  window.addEventListener('beforeunload', cleanup);

  // Listen for reduced motion preference changes
  const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
  if (mediaQuery.addEventListener) {
    mediaQuery.addEventListener('change', () => {
      isReducedMotion = mediaQuery.matches;
      needsUpdate = true;

      if (!animationFrameId && isTabVisible) {
        animationFrameId = requestAnimationFrame(updateAndDraw);
      }
    });
  }
}

// Initialize the animation
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', init);
} else {
  window.requestIdleCallback ? window.requestIdleCallback(init) : setTimeout(init, 1);
}



    Enter fullscreen mode
    


    Exit fullscreen mode
    




The { passive: true } option on event listeners is a small but meaningful performance boost, especially for scroll events. It tells the browser that we won't be calling preventDefault(), allowing it to optimize accordingly.Using requestIdleCallback (with a fallback to setTimeout) means our initialization happens when the browser is less busy, reducing the chance of jank during page load.And proper cleanup is just good citizenship—nobody likes a memory leak.
  
  
  Why This Approach Actually Works
So why is this approach so much better than moving a bunch of DOM elements with CSS transforms?
Canvas Efficiency: Drawing shapes on canvas is vastly more efficient than manipulating the DOM. When you have hundreds of elements, this difference becomes enormous.
Smart Throttling: By throttling scroll events with requestAnimationFrame, we're naturally syncing to the browser's render cycle instead of fighting against it.
Device-Aware Rendering: We're not assuming everyone has the latest hardware. By adapting the number and complexity of shapes based on the device, we ensure a smooth experience across the board.
Accessibility Considerations: The reduced motion detection means users who prefer less animation still get an engaging experience without potentially triggering issues like motion sickness.
Battery Friendliness: By rendering only when necessary and pausing when the tab isn't visible, we're being respectful of users' battery life.

  
  
  Common Mistakes to Avoid
From my experiences building (and fixing) scroll animations, here are some traps you definitely want to avoid:
DOM Domination: Using DOM elements instead of canvas for large numbers of animated objects is asking for trouble. I once had to rescue a project where they tried animating 500+ divs on scroll. The browser was basically crying for mercy.
Scroll Event Overload: Handling scroll events without throttling is performance suicide. I've seen scroll handlers firing 100+ times during a single scroll movement.
Forgetting Mobile: Testing only on your developer machine is a recipe for disaster. What feels smooth on your Core i9 might bring a mid-range phone to its knees.
Ignoring User Preferences: Not accounting for reduced motion preferences might seem minor, but it can literally make some users physically ill. Always respect user preferences.
Layout Thrashing: Repeatedly reading and then writing to the DOM (like getting scroll position, then updating positions, then getting measurements again) can cause multiple forced reflows. Batch your reads and writes!

  
  
  Conclusion
Building scroll animations that actually perform well isn't rocket science, but it does require some forethought and a genuine concern for user experience across all devices. The approach outlined here has served me well across multiple projects, and the principles apply even if you're building something completely different.The next time you're tempted to animate 200 DOM elements on scroll—stop, breathe, and remember there's probably a more efficient way to achieve the same effect. Your users' devices (and batteries) will thank you.👉 Check out the full code and live demo on CodePenHappy scrolling! ✨👋 If you enjoyed this article, you might also like my previous write-up: 8 JavaScript Helper Functions That Make Your UI Feel InstantIt’s packed with practical tools to make your UI not just beautiful—but fast.What performance tricks do you use for smooth animations? Have you found other canvas optimizations that work well? Drop a comment below – I'd love to hear your experiences!