· 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.

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
- Use rAF rather than timers for frame updates.
- Animate transform and opacity (composite-only) where possible.
- Avoid triggering synchronous layout (measure and mutate patterns).
- Respect user preferences (reduced motion) and interruptibility.
- 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
Wrapping up - a recommended approach
- Prefer native browser scroll and CSS effects when possible.
- If JS-driven smoothing is required, start simple: time-based rAF with ease function.
- Add cancellation and input handling so users never feel trapped.
- Profile with devtools and optimize based on concrete measurements.
- 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
- MDN - window.requestAnimationFrame: https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
- MDN - Passive event listeners: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#parameters
- MDN - prefers-reduced-motion: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
- MDN - will-change: https://developer.mozilla.org/en-US/docs/Web/CSS/will-change
- MDN - Intersection Observer API: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
- MDN - Scroll-driven Animations: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Scroll-linked_Animations