When a user scrolls to the bottom of a modal and keeps scrolling, the page behind it starts moving. This is scroll chaining — the browser passes the scroll gesture up through the DOM when the focused container runs out of room. The traditional fix is JavaScript: intercept touchmove events, check whether the container has hit its boundary, and call preventDefault(). There's a CSS property for this.
The property
overscroll-behavior controls what happens when a scroll gesture reaches the edge of a scrollable container. Three values:
-
auto— the default; scroll chains to the parent element -
contain— stops chaining at this element; overscroll effects (bounce, glow highlight) still render -
none— stops chaining and suppresses the overscroll effect entirely
.modal-body {
overflow-y: auto;
overscroll-behavior-y: contain;
}
One property on one element. The browser's own scroll machinery handles containment — no event listener, no JavaScript.
What it replaces
Here's the JavaScript approach, written carefully enough to actually work:
// Before — fragile, performance-hostile
let touchStartY = 0;
modal.addEventListener('touchstart', (e) => {
touchStartY = e.touches[0].clientY;
}, { passive: true });
modal.addEventListener('touchmove', (e) => {
const delta = touchStartY - e.touches[0].clientY;
const atBottom = modal.scrollTop >= modal.scrollHeight - modal.clientHeight;
const atTop = modal.scrollTop <= 0;
if ((atBottom && delta > 0) || (atTop && delta < 0)) {
e.preventDefault(); // ← requires passive: false
}
}, { passive: false }); // ⚠️ kills scroll performance
passive: false is the problem. Browsers optimistically start compositing the scroll on the GPU — when a listener is passive, they don't wait for it. The moment you declare passive: false, the browser has to park the entire scroll gesture, wait for your handler to finish, then decide whether to scroll. You pay that cost on every touchmove event, on every device, whether the user is anywhere near the edge or not. Chrome logs a warning about this in DevTools.
With CSS:
/* After — one declaration, no event listener */
.modal-body {
overflow-y: auto;
overscroll-behavior-y: contain;
}
No passive penalty. No edge-case bugs where the user simultaneously hits the top and bottom boundary during a fast flick. No listener to clean up when the modal unmounts.
Pull-to-refresh and browser navigation
overscroll-behavior controls two other native gestures besides scroll chaining.
Pull-to-refresh on mobile Chrome. Overscrolling past the top of the page triggers the refresh gesture. If you've built a custom refresh indicator, or you're shipping an app shell where the native gesture doesn't belong, disable it on the document:
body {
overscroll-behavior-y: none;
}
Use this deliberately. Pull-to-refresh is a familiar gesture — suppressing it at the body level surprises users on pages that function as documents. Reserve it for true app shells.
Back-navigation swipes on horizontal carousels. On macOS trackpads and some mobile browsers, swiping past the last card in a horizontal scroll container triggers browser back/forward navigation. This is almost never what you want:
.carousel {
overflow-x: auto;
overscroll-behavior-x: contain;
}
The carousel scrolls freely within its bounds; going past the last slide does nothing instead of navigating away.
The shorthand and axis properties
There are two axis-specific variants and a shorthand:
/* Both axes */
overscroll-behavior: contain;
/* Axis-specific */
overscroll-behavior-x: contain;
overscroll-behavior-y: none;
/* Shorthand: x-value then y-value */
overscroll-behavior: contain none;
The most common pattern is overscroll-behavior-y: contain on vertically scrollable panels — modals, drawers, sidebars, dropdown bodies — where you want scroll to stay inside the component without disabling anything horizontally.
Browser support
overscroll-behavior is Baseline 2019: Chrome 63 (December 2017), Firefox 59 (March 2018), Safari 16 (September 2022). iOS Safari was the last major holdout, shipping support in late 2022. It's been broadly available for several years across browsers, Node.js-powered WebViews, and PWA contexts.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The takeaway
Search your codebase for touchmove listeners that call e.preventDefault() to stop scroll from escaping a container — those are scroll-chaining patches. Replace the listener with overscroll-behavior: contain on the scrollable element and delete the JavaScript. While you're at it, add overscroll-behavior-x: contain to any horizontal scroll container where you don't want browser navigation triggered at the edges. The CSS path doesn't touch the browser's scroll machinery, scales to every device with no tuning, and removes a passive-false listener that was slowing down every scroll gesture regardless of whether it hit the boundary.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (0)