← blog
CSSAugust 12, 2026 · 4 min read

You're blocking touchmove events to contain scroll. overscroll-behavior does it natively.

When a scrollable container hits its edge, the page behind it starts scrolling too. Developers block this with JavaScript event handlers or body overflow tricks. overscroll-behavior is the CSS property that turns off scroll chaining in one line.

Parsa Jiravand · Frontend engineer · building bestpractic
You're blocking touchmove events to contain scroll. `overscroll-behavior` does it natively.

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.

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
CSS
1
2
3
4
.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.

Here's the JavaScript approach, written carefully enough to actually work:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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:

CSS
1
2
3
4
5
/* 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.

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:

CSS
1
2
3
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:

CSS
1
2
3
4
.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.

There are two axis-specific variants and a shorthand:

CSS
1
2
3
4
5
6
7
8
9
/* 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.

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.

Runs right in your browser — poke at it and watch the concept react live.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

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.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.