Skip to main content

How to Handle Scrolling Events in JavaScript

Scrolling is one of the most frequent user interactions on the web. Every time a user moves through a page, the browser fires scroll events that you can use to build dynamic, responsive interfaces. From sticky navigation bars that appear as you scroll down, to lazy-loaded images that only fetch when they enter the viewport, to infinite scroll feeds that load more content automatically, scroll-based features are everywhere in modern web applications.

However, scroll events come with unique challenges. They fire at an extremely high rate, making performance a real concern. Preventing scrolling is not as straightforward as calling preventDefault(). And many patterns that developers traditionally built with scroll listeners now have better, more performant alternatives.

This guide covers the scroll event in detail, techniques for preventing scrolling, practical scroll-based UI patterns, critical performance optimizations, and the Intersection Observer API that replaces many traditional scroll listeners entirely.

The scroll Event

The scroll event fires whenever an element's scroll position changes. This includes both the page itself (the document) and any scrollable element with overflowing content.

Listening for Page Scroll

window.addEventListener('scroll', () => {
console.log(`Scrolled to: ${window.scrollY}px from top`);
});

The window.scrollY (or its alias window.pageYOffset) property tells you how many pixels the document has been scrolled vertically from the top. Similarly, window.scrollX (or window.pageXOffset) gives the horizontal scroll position.

window.addEventListener('scroll', () => {
console.log(`Vertical: ${window.scrollY}px`);
console.log(`Horizontal: ${window.scrollX}px`);
});

Listening for Element Scroll

Any element with overflow: auto, overflow: scroll, or overflow-y: scroll can be scrolled independently. The scroll event fires on that specific element:

<div id="scrollable-box" style="height: 200px; overflow-y: auto;">
<div style="height: 1000px; padding: 20px;">
Lots of content here...
</div>
</div>

<script>
const box = document.getElementById('scrollable-box');

box.addEventListener('scroll', () => {
console.log(`Box scrolled to: ${box.scrollTop}px`);
});
</script>

For scrollable elements, use element.scrollTop and element.scrollLeft instead of the window properties.

The scroll Event Does Not Bubble Conventionally

The scroll event has unusual bubbling behavior. When fired on a scrollable element, it does not bubble to parent elements. However, when fired on the document, it can be detected on window.

// ✅ Work: detecting document scroll on window
window.addEventListener('scroll', () => {
console.log('Page scrolled');
});

// ✅ Works: detecting scroll on the element itself
scrollableDiv.addEventListener('scroll', () => {
console.log('Div scrolled');
});

// ❌ Does NOT work: scroll events on child don't bubble to parent
parentDiv.addEventListener('scroll', () => {
// This will NOT fire when a scrollable child is scrolled
});
note

Because scroll does not bubble, you cannot use event delegation for scroll events. Each scrollable element needs its own listener. For the document/page scroll, attach the listener to window.

Scroll Direction Detection

The scroll event does not tell you which direction the user scrolled. You need to track the previous scroll position and compare:

let lastScrollY = window.scrollY;

window.addEventListener('scroll', () => {
const currentScrollY = window.scrollY;

if (currentScrollY > lastScrollY) {
console.log('Scrolling DOWN');
} else if (currentScrollY < lastScrollY) {
console.log('Scrolling UP');
}

lastScrollY = currentScrollY;
});

This pattern is the foundation for many scroll-based UI behaviors, like showing a navigation bar when scrolling up and hiding it when scrolling down.

Scroll Position and Document Dimensions

Understanding the relationship between scroll position, viewport size, and document size is essential for scroll-based features:

window.addEventListener('scroll', () => {
const scrollTop = window.scrollY;
const viewportHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;

// How far through the page (0 to 1)
const scrollPercentage = scrollTop / (documentHeight - viewportHeight);

// Has the user scrolled to the bottom?
const isAtBottom = scrollTop + viewportHeight >= documentHeight - 1;

console.log(`Progress: ${(scrollPercentage * 100).toFixed(1)}%`);
console.log(`At bottom: ${isAtBottom}`);
});

Key properties to remember:

PropertyDescription
window.scrollYPixels scrolled from the top of the document
window.innerHeightViewport height (visible area)
document.documentElement.scrollHeightTotal height of the document content
document.documentElement.clientHeightViewport height (excluding scrollbar)
element.scrollTopPixels scrolled within a specific element
element.scrollHeightTotal scrollable height of the element's content
element.clientHeightVisible height of the element (excluding scrollbar)

Preventing Scrolling

Preventing scrolling is trickier than you might expect. Unlike most events, calling preventDefault() on the scroll event itself does not prevent scrolling. The scroll event fires after the scroll has already happened. It is a notification, not a request for permission.

Using CSS overflow: hidden

The most reliable way to prevent scrolling is with CSS:

// Disable scrolling
document.body.style.overflow = 'hidden';

// Re-enable scrolling
document.body.style.overflow = '';

This is commonly used when opening a modal dialog to prevent the background from scrolling:

function openModal() {
// Save the current scroll position
const scrollY = window.scrollY;

// Prevent scrolling
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';

// Show modal...
}

function closeModal() {
// Restore scrolling
const scrollY = parseInt(document.body.style.top || '0', 10) * -1;

document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';

// Restore scroll position
window.scrollTo(0, scrollY);
}
warning

Simply setting overflow: hidden on document.body can cause the page to jump to the top. The pattern above uses position: fixed with a negative top value to preserve the visual position. When scrolling is re-enabled, it restores the original scroll position programmatically.

Preventing Scroll via the wheel Event

While you cannot prevent scrolling by handling the scroll event, you can prevent it by handling the events that cause scrolling. The wheel event fires before the scroll happens and supports preventDefault():

// Prevent mouse wheel scrolling on a specific element
element.addEventListener('wheel', (event) => {
event.preventDefault();
}, { passive: false }); // Must be non-passive!

The { passive: false } option is critical here. By default, modern browsers treat wheel and touchmove listeners as passive, meaning they assume you will not call preventDefault(). This allows the browser to scroll immediately without waiting for your JavaScript to execute. If you want to actually prevent scrolling, you must explicitly set passive: false.

// ❌ This will NOT prevent scrolling in modern browsers
// (passive: true is the default for wheel/touchmove)
element.addEventListener('wheel', (event) => {
event.preventDefault(); // Ignored! Browser shows a console warning
});

// ✅ This prevents scrolling
element.addEventListener('wheel', (event) => {
event.preventDefault(); // Works because passive is false
}, { passive: false });

Preventing Touch Scroll

On touch devices, scrolling is triggered by touchmove events:

element.addEventListener('touchmove', (event) => {
event.preventDefault();
}, { passive: false });

Conditional Scroll Prevention

A practical use case is preventing a scrollable container from "leaking" scrolls to the parent page when it reaches its top or bottom boundary:

const scrollablePanel = document.getElementById('panel');

scrollablePanel.addEventListener('wheel', (event) => {
const { scrollTop, scrollHeight, clientHeight } = scrollablePanel;
const atTop = scrollTop === 0;
const atBottom = scrollTop + clientHeight >= scrollHeight;

// Scrolling up at the top, or scrolling down at the bottom
if ((event.deltaY < 0 && atTop) || (event.deltaY > 0 && atBottom)) {
event.preventDefault(); // Prevent page from scrolling
}
}, { passive: false });

A cleaner modern alternative is the CSS property overscroll-behavior:

/* Prevent scroll chaining to parent */
.scrollable-panel {
overscroll-behavior: contain;
}

This CSS-only solution prevents the parent page from scrolling when the panel reaches its boundary, without any JavaScript.

Scroll-Based UI Patterns

A common pattern is a navigation bar that hides when scrolling down (to give more content space) and reappears when scrolling up:

<style>
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: #2c3e50;
color: white;
display: flex;
align-items: center;
padding: 0 20px;
transition: transform 0.3s ease;
z-index: 1000;
}
.navbar.hidden {
transform: translateY(-100%);
}
.content {
margin-top: 60px;
padding: 20px;
}
</style>

<nav class="navbar" id="navbar">Navigation Bar</nav>
<div class="content">
<!-- Long content here -->
</div>

<script>
const navbar = document.getElementById('navbar');
let lastScrollY = window.scrollY;
let ticking = false;

window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
const currentScrollY = window.scrollY;

if (currentScrollY > lastScrollY && currentScrollY > 60) {
// Scrolling down and past the navbar height
navbar.classList.add('hidden');
} else {
// Scrolling up
navbar.classList.remove('hidden');
}

lastScrollY = currentScrollY;
ticking = false;
});
ticking = true;
}
});
</script>

Reading Progress Bar

A progress bar that shows how far through an article the user has scrolled:

<style>
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: #3498db;
z-index: 1001;
transition: width 0.1s;
}
</style>

<div class="progress-bar" id="progress"></div>

<script>
const progressBar = document.getElementById('progress');

window.addEventListener('scroll', () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = (scrollTop / docHeight) * 100;

progressBar.style.width = `${progress}%`;
});
</script>

Back-to-Top Button

A button that appears after scrolling down a certain distance:

const backToTop = document.getElementById('back-to-top');

window.addEventListener('scroll', () => {
if (window.scrollY > 500) {
backToTop.classList.add('visible');
} else {
backToTop.classList.remove('visible');
}
});

backToTop.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});

Infinite Scroll

Loading more content when the user approaches the bottom of the page:

let isLoading = false;
let page = 1;

window.addEventListener('scroll', () => {
if (isLoading) return;

const scrollTop = window.scrollY;
const viewportHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;

// Start loading when within 300px of the bottom
if (scrollTop + viewportHeight >= documentHeight - 300) {
loadMoreContent();
}
});

async function loadMoreContent() {
isLoading = true;
showLoadingSpinner();

try {
page++;
const response = await fetch(`/api/posts?page=${page}`);
const posts = await response.json();

if (posts.length === 0) {
// No more content: remove the scroll listener
showEndMessage();
return;
}

const container = document.getElementById('post-list');
for (const post of posts) {
const element = createPostElement(post);
container.appendChild(element);
}
} catch (error) {
console.error('Failed to load more content:', error);
page--; // Retry on next scroll
} finally {
isLoading = false;
hideLoadingSpinner();
}
}
tip

While infinite scroll built with the scroll event works, the Intersection Observer API (covered later in this guide) is a more performant and cleaner approach for detecting when the user reaches the bottom of the content.

Lazy Loading Images (Traditional Scroll Approach)

Before Intersection Observer existed, lazy loading images relied on scroll event listeners:

// Traditional approach: works but has performance issues
function lazyLoadImages() {
const images = document.querySelectorAll('img[data-src]');

images.forEach(img => {
const rect = img.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight + 200 && rect.bottom > -200;

if (isVisible) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
}
});
}

window.addEventListener('scroll', lazyLoadImages);
window.addEventListener('load', lazyLoadImages); // Also check on page load

This approach works but forces the browser to call getBoundingClientRect() on every image during every scroll event, which can cause layout thrashing and jank. The Intersection Observer solution shown later in this guide is significantly better.

Performance: Throttling Scroll Handlers and Passive Listeners

The scroll event fires at a very high rate. On a typical display refreshing at 60fps, the scroll event can fire 60 or more times per second during active scrolling. On high-refresh-rate displays (120Hz, 144Hz), it fires even more often. Running expensive operations on every single scroll event causes visible performance problems: janky scrolling, dropped frames, and unresponsive pages.

The Problem: Layout Thrashing

The most common performance mistake in scroll handlers is reading layout properties (which forces the browser to recalculate layout) and then writing to the DOM (which invalidates the layout), repeatedly:

// ❌ Extremely bad performance: layout thrashing
window.addEventListener('scroll', () => {
const elements = document.querySelectorAll('.animate-on-scroll');

elements.forEach(el => {
const rect = el.getBoundingClientRect(); // Forces layout recalculation
if (rect.top < window.innerHeight) {
el.style.transform = 'translateY(0)'; // Invalidates layout
el.style.opacity = '1'; // Invalidates layout again
}
});
});

Solution 1: requestAnimationFrame Throttling

The simplest effective optimization is to use requestAnimationFrame to batch your scroll handling with the browser's rendering cycle:

let ticking = false;

window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
handleScroll(); // Runs at most once per frame
ticking = false;
});
ticking = true;
}
});

function handleScroll() {
const scrollY = window.scrollY;
// Your scroll logic here
updateNavbar(scrollY);
updateProgressBar(scrollY);
}

This ensures your handler runs at most once per animation frame (typically 60 times per second), no matter how many scroll events actually fire.

Solution 2: Throttle Function

For more control over the execution rate, use a throttle function that limits execution to a fixed interval:

function throttle(fn, delay) {
let lastCall = 0;
let timeoutId = null;

return function (...args) {
const now = Date.now();

if (now - lastCall >= delay) {
lastCall = now;
fn.apply(this, args);
} else {
// Schedule a trailing call
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
lastCall = Date.now();
fn.apply(this, args);
}, delay - (now - lastCall));
}
};
}

// Execute at most every 100ms
window.addEventListener('scroll', throttle(() => {
console.log('Throttled scroll handler');
updateUI();
}, 100));

The trailing call ensures the final scroll position is always handled, even if the last scroll event falls between throttle intervals.

Solution 3: Debounce for "Scroll End" Detection

Sometimes you want to run code only after scrolling has stopped, not during scrolling. Debouncing is the right tool:

function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}

// Runs 150ms after the user stops scrolling
window.addEventListener('scroll', debounce(() => {
console.log('Scrolling stopped');
snapToNearestSection();
}, 150));

There is also a newer native event for this. The scrollend event fires when scrolling completes, available in modern browsers:

window.addEventListener('scrollend', () => {
console.log('Scrolling finished (native event)');
snapToNearestSection();
});

Passive Event Listeners

Modern browsers default wheel and touchmove listeners to passive mode. This means the browser assumes your listener will not call preventDefault() and can start scrolling immediately without waiting for your JavaScript to execute.

For scroll event listeners, passivity is less of an issue because scroll cannot be prevented anyway. But explicitly marking your scroll listeners as passive is still a good practice because it signals your intent to the browser:

// Explicitly passive: browser can optimize
window.addEventListener('scroll', handleScroll, { passive: true });

The performance difference is most noticeable with wheel and touchmove:

// ❌ Non-passive wheel listener blocks smooth scrolling
// (browser waits for your JS before scrolling)
element.addEventListener('wheel', (e) => {
// Even if you don't call preventDefault(),
// the browser must wait to find out
logScrollData(e);
});

// ✅ Passive wheel listener: browser scrolls immediately
element.addEventListener('wheel', (e) => {
logScrollData(e);
}, { passive: true });

// ⚠️ Non-passive only when you actually need to prevent scrolling
element.addEventListener('wheel', (e) => {
e.preventDefault(); // Requires passive: false
}, { passive: false });
warning

If you add a wheel or touchmove listener with { passive: false } and do heavy work inside it, you will visibly degrade scroll performance. The browser cannot scroll until your JavaScript completes. Only use passive: false when you genuinely need to prevent scrolling, and keep the handler as fast as possible.

Performance Checklist

Here is a summary of best practices for scroll event performance:

// ✅ 1. Use requestAnimationFrame to batch updates
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
doWork();
ticking = false;
});
ticking = true;
}
}, { passive: true }); // ✅ 2. Mark as passive

function doWork() {
// ✅ 3. Avoid reading layout in scroll handlers
// Use cached values or CSS transforms instead of getBoundingClientRect()

// ✅ 4. Avoid DOM writes that trigger layout
// Prefer classList toggling and CSS transitions over direct style manipulation

// ✅ 5. Consider using Intersection Observer instead
// (see next section)
}

Intersection Observer API: The Modern Alternative

The Intersection Observer API is the modern replacement for many scroll-based patterns. Instead of listening to every scroll event and manually calculating element positions, you tell the browser: "Notify me when this element enters or leaves the viewport." The browser handles all the position tracking internally, far more efficiently than JavaScript can.

Basic Usage

const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(`${entry.target.id} is visible`);
} else {
console.log(`${entry.target.id} is not visible`);
}
});
});

// Observe one or more elements
observer.observe(document.getElementById('section-1'));
observer.observe(document.getElementById('section-2'));
observer.observe(document.getElementById('section-3'));

The callback receives an array of IntersectionObserverEntry objects, each with useful properties:

PropertyDescription
entry.isIntersectingWhether the element is currently in the viewport
entry.intersectionRatioHow much of the element is visible (0 to 1)
entry.boundingClientRectElement's bounding rectangle
entry.rootBoundsViewport (or root) bounds
entry.targetThe observed element
entry.timeTimestamp of the intersection change

Configuration Options

The IntersectionObserver constructor accepts an options object:

const observer = new IntersectionObserver(callback, {
root: null, // null = viewport; or a scrollable parent element
rootMargin: '0px', // Margin around the root (like CSS margin)
threshold: 0 // At what visibility ratio to trigger (0 to 1)
});

rootMargin lets you expand or shrink the detection area. A positive margin triggers the callback before the element actually enters the viewport:

// Trigger 200px before the element reaches the viewport
const observer = new IntersectionObserver(callback, {
rootMargin: '200px 0px' // 200px top/bottom, 0px left/right
});

threshold controls at what visibility percentage the callback fires:

// Fire when element is 0%, 25%, 50%, 75%, or 100% visible
const observer = new IntersectionObserver(callback, {
threshold: [0, 0.25, 0.5, 0.75, 1.0]
});

Lazy Loading Images with Intersection Observer

This is dramatically simpler and more performant than the scroll-based approach:

const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
imageObserver.unobserve(img); // Stop watching once loaded
}
});
}, {
rootMargin: '200px 0px' // Start loading 200px before entering viewport
});

// Observe all lazy images
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
tip

Modern browsers also support native lazy loading with the loading="lazy" attribute on <img> elements. For most cases, <img src="photo.jpg" loading="lazy" /> is all you need. Use Intersection Observer when you need custom behavior like fade-in animations or loading indicators.

Infinite Scroll with Intersection Observer

Instead of calculating scroll position on every scroll event, observe a sentinel element at the bottom of your content:

<div id="post-list">
<!-- Posts render here -->
</div>
<div id="sentinel" style="height: 1px;"></div>

<script>
const sentinel = document.getElementById('sentinel');
let page = 1;
let isLoading = false;

const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading) {
loadMorePosts();
}
}, {
rootMargin: '300px' // Trigger 300px before sentinel is visible
});

observer.observe(sentinel);

async function loadMorePosts() {
isLoading = true;
page++;

try {
const response = await fetch(`/api/posts?page=${page}`);
const posts = await response.json();

if (posts.length === 0) {
observer.unobserve(sentinel);
sentinel.textContent = 'No more posts';
return;
}

const container = document.getElementById('post-list');
for (const post of posts) {
container.appendChild(createPostElement(post));
}
} catch (error) {
console.error('Load failed:', error);
page--;
} finally {
isLoading = false;
}
}
</script>

This approach uses zero scroll event listeners. The browser handles all the position tracking internally.

Scroll-Triggered Animations

Animating elements as they enter the viewport is a perfect use case for Intersection Observer:

<style>
.fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
</style>

<script>
const animationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
animationObserver.unobserve(entry.target); // Animate only once
}
});
}, {
threshold: 0.15 // Trigger when 15% visible
});

document.querySelectorAll('.fade-in').forEach(el => {
animationObserver.observe(el);
});
</script>

Active Section Highlighting

Highlighting the current section in a navigation menu as the user scrolls:

const sections = document.querySelectorAll('section[id]');
const navLinks = document.querySelectorAll('.nav-link');

const sectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Remove active from all links
navLinks.forEach(link => link.classList.remove('active'));

// Add active to the matching link
const activeLink = document.querySelector(
`.nav-link[href="#${entry.target.id}"]`
);
if (activeLink) {
activeLink.classList.add('active');
}
}
});
}, {
rootMargin: '-50% 0px -50% 0px', // Only the middle of the viewport counts
threshold: 0
});

sections.forEach(section => {
sectionObserver.observe(section);
});

When to Use Scroll Events vs. Intersection Observer

Use CaseBest Approach
Lazy loading images/contentIntersection Observer
Infinite scrollIntersection Observer
Scroll-triggered animationsIntersection Observer
Active section highlightingIntersection Observer
Reading progress barScroll event (needs exact scroll position)
Show/hide navbar on scroll directionScroll event (needs direction calculation)
Parallax effectsScroll event + requestAnimationFrame
Scroll snappingCSS scroll-snap-type or scrollend event
"Back to top" button visibilityIntersection Observer or scroll event
note

As a general rule, if you are asking "Is this element in the viewport?" use Intersection Observer. If you need the exact scroll position or scroll delta for continuous calculations (like parallax), use the scroll event with requestAnimationFrame.

Cleaning Up Observers

Always disconnect your observers when they are no longer needed to prevent memory leaks:

// Stop observing a specific element
observer.unobserve(element);

// Stop observing all elements and disconnect completely
observer.disconnect();

For single-page applications where views are created and destroyed dynamically, disconnecting observers during cleanup is essential:

class ScrollAnimatedView {
constructor(container) {
this.observer = new IntersectionObserver(this.handleIntersection.bind(this));
container.querySelectorAll('.animate').forEach(el => {
this.observer.observe(el);
});
}

handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}

destroy() {
this.observer.disconnect(); // Clean up when view is removed
}
}

Summary

Scroll events power many essential web interactions, but they require careful handling to avoid performance problems:

  • The scroll event fires at a very high rate during scrolling and cannot be used to prevent scrolling. It does not bubble from child elements to parents.
  • Preventing scrolling requires CSS (overflow: hidden) or intercepting the events that cause scrolling (wheel, touchmove) with { passive: false }. The CSS property overscroll-behavior: contain prevents scroll chaining without JavaScript.
  • Scroll-based UI patterns like sticky navbars, progress bars, and infinite scroll are common but must be implemented with performance in mind.
  • Throttle scroll handlers using requestAnimationFrame or a throttle function. Mark listeners as { passive: true } when you do not need to prevent default behavior. Avoid layout thrashing by minimizing getBoundingClientRect() calls inside scroll handlers.
  • The Intersection Observer API replaces most scroll listener patterns (lazy loading, infinite scroll, scroll animations, section tracking) with a more performant, declarative approach. The browser handles all position tracking internally, eliminating the need for manual calculations on every scroll event.

For any new project, reach for Intersection Observer first. Fall back to scroll event listeners only when you need continuous scroll position tracking that Intersection Observer cannot provide.