· tips  · 5 min read

Mastering Performance: Using requestAnimationFrame for Smooth Scrolling

Learn how to build smooth, high-performance scroll animations using requestAnimationFrame. This article walks through time-based and velocity-based techniques, performance best practices, accessibility considerations, and debugging tips to create fluid scrolling experiences.

Learn how to build smooth, high-performance scroll animations using requestAnimationFrame. This article walks through time-based and velocity-based techniques, performance best practices, accessibility considerations, and debugging tips to create fluid scrolling experiences.

Why requestAnimationFrame for Smooth Scrolling?

Browsers render frames at roughly 60fps (or higher on modern displays). requestAnimationFrame (rAF) schedules your JavaScript to run right before the browser repaints, giving you a consistent and efficient place to change properties that affect visual output. Unlike setTimeout/setInterval, rAF helps avoid jank and provides a time-stamp for smooth, time-based animations.

  • Coordinated with the browser frame rate
  • Minimizes layout thrashing when used correctly
  • Easier to keep animations time-based and device-independent

References: MDN: window.requestAnimationFrame


The core principles for performant scroll animations

  1. Use rAF rather than timers for frame updates.
  2. Animate transform and opacity (composite-only) where possible.
  3. Avoid triggering synchronous layout (measure and mutate patterns).
  4. Respect user preferences (reduced motion) and interruptibility.
  5. Use passive listeners for scroll-related input.

See: MDN: Passive event listeners


Basic pattern: rAF read → write loop

The most important pattern when animating is to separate reads (measure) from writes (mutate). Doing both in the same rAF callback is OK, but you must avoid interleaving many reads/writes across the DOM which causes layout thrashing.

let rafId = null;
let latestScrollY = 0;

function onScroll() {
  // If you use a non-passive listener, beware it can block scroll
  latestScrollY = window.scrollY || window.pageYOffset;
  if (!rafId) rafId = requestAnimationFrame(update);
}

function update(timestamp) {
  // READ (measure) - avoid reading after writing if possible
  const y = latestScrollY;

  // WRITE (mutate)
  // Example: move a sticky element using translateY
  document.querySelector('.hero').style.transform = `translateY(${y * 0.5}px)`;

  rafId = null; // ready for next rAF
}

window.addEventListener('scroll', onScroll, { passive: true });

Notes:

  • Use { passive: true } to avoid blocking scroll on touch/wheel.
  • Cache DOM queries outside the loop.

Example 1 - Time-based smooth scrollTo

A simple easing-based scrollTo using rAF. This works regardless of frame rate because it uses time deltas.

function smoothScrollTo(targetY, duration = 500) {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    window.scrollTo(0, targetY);
    return;
  }

  const startY = window.scrollY || window.pageYOffset;
  const startTime = performance.now();

  function easeOutCubic(t) {
    return 1 - Math.pow(1 - t, 3);
  }

  function frame(now) {
    const elapsed = now - startTime;
    const t = Math.min(1, elapsed / duration);
    const eased = easeOutCubic(t);
    const current = startY + (targetY - startY) * eased;
    window.scrollTo(0, Math.round(current));

    if (t < 1) {
      requestAnimationFrame(frame);
    }
  }

  requestAnimationFrame(frame);
}

Important points:

  • Using performance.now() ensures time accuracy.
  • prefers-reduced-motion check provides an accessibility-friendly fallback. More here: MDN: prefers-reduced-motion

Example 2 - Velocity-based/fling-style smoothing

A velocity-based approach provides continuous smoothing even when the user is actively scrolling (or flicking on touch devices). It models motion rather than strictly interpolating between two points.

let current = 0; // current visual scroll position
let target = 0; // actual scroll position we want to follow
let raf = null;
const ease = 0.125; // lower = smoother/slower follow

function onScroll() {
  target = window.scrollY || window.pageYOffset;
  if (!raf) raf = requestAnimationFrame(loop);
}

function loop() {
  const delta = target - current;
  current += delta * ease; // exponential smoothing (LERP)

  // Apply transform to an inner container instead of the whole page
  document.querySelector('.content-wrap').style.transform =
    `translateY(${-current}px)`;

  if (Math.abs(delta) > 0.5) {
    raf = requestAnimationFrame(loop);
  } else {
    raf = null;
  }
}

window.addEventListener('scroll', onScroll, { passive: true });

Notes:

  • This technique often requires locking native scrolling (e.g., body overflow hidden) and manually translating content, which has implications for accessibility and browser scroll behaviors.
  • When implementing, ensure keyboard navigation, focus management, and scroll restoration still work.

Avoiding layout thrash and expensive properties

Prefer animating these properties:

  • transform (translate, scale, rotate)
  • opacity

Avoid animating these when possible (they force layout/repaint):

  • width, height
  • top, left, margin
  • anything that changes geometry of the document flow

Use will-change sparingly to hint to the browser to create a composite layer:

.hero {
  will-change: transform;
}

Reference: MDN: will-change


Handling user interrupts and accessibility

A smooth scrolling UI must still allow users to interrupt animations - e.g., when they start scrolling or press keys. Best practices:

  • Cancel in-progress rAF animations on user input (wheel, touchstart, keydown).
  • Respect prefers-reduced-motion and offer an instant or simpler fallback.
  • If you implement a custom scroller (translating content), ensure focusable elements are reachable and keyboard scrolling still moves content.
  • Provide a user control to disable fancy scrolling if needed.
function cancelScrollAnimation() {
  if (rafId) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
}

['wheel', 'touchstart', 'keydown'].forEach(evt =>
  window.addEventListener(evt, cancelScrollAnimation, { passive: true })
);

Combining IntersectionObserver with rAF

Use IntersectionObserver to detect when elements enter the viewport and then trigger rAF-driven animations - this avoids running animations for offscreen elements.

const io = new IntersectionObserver(
  entries => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        // Kick off rAF-driven reveal animation
        requestAnimationFrame(() => entry.target.classList.add('visible'));
      }
    });
  },
  { threshold: 0.1 }
);

document.querySelectorAll('.reveal').forEach(el => io.observe(el));

Reference: MDN: Intersection Observer API


Practical performance checklist

  • Use rAF for frame-synced updates.
  • Animate transforms and opacity, not layout properties.
  • Separate reads and writes; batch your DOM access.
  • Use passive event listeners for wheel/touch to avoid blocking.
  • Respect reduced-motion and provide opt-outs.
  • Cache DOM nodes and measurement results where possible.
  • Cancel animations on user input and handle interruptions gracefully.
  • Use IntersectionObserver to avoid offscreen work.
  • Profile and measure with browser devtools: FPS and Timeline traces.

Profiling and measuring jank

Tools:

  • Chrome DevTools Performance panel - record timeline and look for long tasks or dropped frames.
  • Performance panel’s “FPS” meter shows frame rate live.
  • Use console.time()/console.timeEnd() for coarse timing of JS blocks.

Look for:

  • Long scripting tasks (expensive JS)
  • Forced synchronous layouts (layout thrash)
  • Large paint region changes

When to use native CSS/Browser features instead

Some scroll-linked effects can be done with CSS only (e.g., sticky positioning, simple transitions). Additionally, the emerging CSS Scroll-Linked Animations / ScrollTimeline API can express scroll-driven animations without continuous JS; however browser support is still evolving.

Reference: MDN: Scroll-driven Animations


  1. Prefer native browser scroll and CSS effects when possible.
  2. If JS-driven smoothing is required, start simple: time-based rAF with ease function.
  3. Add cancellation and input handling so users never feel trapped.
  4. Profile with devtools and optimize based on concrete measurements.
  5. Always respect accessibility (prefers-reduced-motion) and keyboard focus behavior.

With these steps you’ll be able to build smoother, more responsive scroll experiences while keeping CPU and battery usage reasonable. Write predictable, cancelable, and accessible animations - and let rAF be your frame-accurate timing ally.

References and further reading

Back to Blog

Related Posts

View All Posts »
Microtasks vs Macrotasks: The Great JavaScript Showdown

Microtasks vs Macrotasks: The Great JavaScript Showdown

A deep dive into microtasks and macrotasks in JavaScript: what they are, how the event loop treats them, how they affect rendering and UX, illustrative diagrams and real-world examples, plus practical guidance to avoid performance pitfalls.

Bitwise Swapping: A Deeper Dive into JavaScript's Oddities

Bitwise Swapping: A Deeper Dive into JavaScript's Oddities

An in-depth look at swapping variables using the bitwise XOR trick in JavaScript: how it works, why it sometimes bends expectations, practical use-cases, and the pitfalls you must know (ToInt32 conversion, aliasing, typed arrays, BigInt).