TL;DR overscroll-behavior: contain stops scroll from chaining to the page once a scroll container hits its edge, so use it on drawers and modal lists. On a horizontal table or carousel use overscroll-behavior-x: contain only, keep the height natural, and drop wheel handlers that call preventDefault.
overscroll-behavior: contain stops a scroll container from passing leftover scroll to the page, but it is also one of the reasons a table or carousel steals the page scroll. When that happens, it's almost always one of four things: the element became a vertical scroll container by accident, overscroll-behavior: contain is trapping the wheel, a wheel or touch handler is calling preventDefault() on events it shouldn't touch, or a modal left overflow: hidden on <html> or <body> after it closed. For a horizontal table or carousel the fix is usually overflow-x: auto with overscroll-behavior-x: contain, a fixed or natural height so it never scrolls vertically, and no wheel handler at all.
I've hit all four in dashboards and cart drawers. Below is each cause with the fix, in React and Tailwind v4. The components come from a test page in a fresh Next.js 15 app that passes tsc and next build, and I checked the wheel and scroll-lock behavior in headless Chromium.
This surprises people. You set only overflow-x: auto on a wrapper, but overflow-y doesn't stay visible. The CSS rule is that if one axis is neither visible nor clip, a visible on the other axis computes to auto. MDN's overflow page describes it. So your table wrapper is a scroll container on both axes.
That's harmless while the content fits vertically. It turns into a scroll trap when the wrapper has a max-height, a fixed h-* class, or a child that is a few pixels taller than the box (a focus ring, a shadow, a dropdown). The wheel then scrolls the wrapper first, and the page only moves after the wrapper hits its edge, or never.
The fix for a wide table: let the height be natural, and only contain the horizontal axis.
overscroll-x-contain compiles to overscroll-behavior-x: contain. It stops horizontal scrolling from chaining to the page when you reach the end of the table, which on some trackpads and browsers is what fires the back/forward swipe. Vertical wheel events pass straight through to the page.
Stack Cart Drawer running live. Products fly into the cart as discs; open it and they unfold into line items.
overscroll-behavior decides what happens when a scroll container reaches its edge. The default, auto, chains the remaining scroll to the parent. contain keeps it inside the element.
That makes overscroll-behavior: contain (Tailwind overscroll-contain) right for a drawer or a modal that scrolls vertically: when the list ends, the page behind it shouldn't start moving. It's wrong on a horizontal table or carousel, because it also blocks vertical chaining, and if the element can scroll vertically at all, the page stops scrolling while the pointer is over it.
This one fills a lot of forum threads. overscroll-behavior only does something on an element that is actually scrolling. If a menu or a short drawer has overflow: auto but its content fits, there is no overflow, the element isn't scrollable, and the wheel or swipe goes straight to the page as if the property weren't there. People see it as "contain is broken until I shrink the window", because shrinking makes the content overflow.
Two ways out. For a full-screen menu or a modal, contain is the wrong tool anyway: lock the page scroll while it's open (the hook further down). For a panel that's sometimes short and sometimes long, accept that the short state lets the page scroll, or give the panel a fixed height so it reliably overflows. Don't fight it with a wheel handler.
The other values are worth knowing too. none does what contain does and also turns off the browser's own overscroll effect for that element, like the bounce or the pull-to-refresh on the page itself. On the root (html), overscroll-behavior-y: none is the usual way to stop pull-to-refresh in a web app. In Tailwind the classes are overscroll-auto, overscroll-contain and overscroll-none, plus -x- and -y- variants such as overscroll-x-contain.
The next cause is code. Someone adds onWheel to a zoomable chart or a custom carousel and calls e.preventDefault() to stop the page from moving. In React it doesn't work:
React attaches its wheel, touchstart and touchmove listeners as passive, and a passive listener can't cancel scrolling. When I scrolled over this box in Chromium, the box scrolled anyway and the console printed:
Unable to preventDefault inside passive event listener invocation.
If you really need to take over the wheel, attach a native listener with { passive: false } and decide case by case. This canvas only takes the wheel when the user holds Ctrl or Cmd (which is also what a trackpad pinch sends), so normal scrolling still moves the page:
tsx
useEffect(() => { const el = ref.current; if (!el) return; function onWheel(e: WheelEvent) { // Only take over the wheel when the user is zooming (ctrl/cmd or pinch). if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); setScale((s) => Math.min(4, Math.max(0.5, s - e.deltaY * 0.01))); } // passive: false is what makes preventDefault() work here. el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel);}, []);
In the same test, a plain element with a native { passive: false } listener calling preventDefault() stayed at scrollTop 0, while the React onWheel version scrolled by the full 120 pixels. The passive option is documented on MDN's addEventListener page.
The bug to avoid is the unconditional version: a non-passive listener that always calls preventDefault(). That's the textbook way to make a widget eat the page scroll.
For a carousel you drag with your finger (a card stack, a swipe-to-dismiss), the browser has to know which gestures are yours. touch-action tells it. With touch-action: pan-y, the browser keeps handling vertical panning, so the page scrolls, and your pointer handlers get the horizontal movement:
Leave touch-action at its default and the browser may start a page scroll in the middle of your drag. Set it to none and the page can't be scrolled at all while the finger starts on the card, which on a phone, where the card is half the screen, feels exactly like a stolen scroll.
The last cause looks different: after closing a cart drawer or a modal, the whole page won't scroll. The lock set overflow: hidden on <html> or <body> when it opened and never removed it. It happens when the cleanup runs on a code path that doesn't fire (a route change, an unmount during an animation), or when two overlays share one lock and the first to close restores the wrong value.
This hook keeps a counter so nested overlays work, saves the previous inline styles, and restores them in the effect's cleanup, which React runs on close and on unmount:
In my test, document.documentElement.style.overflow read hidden while the drawer was open and an empty string after closing it. If a page is stuck, check that value in the console first; it tells you straight away whether a lock leaked.
Desktop Chrome hides some of these. Before shipping, try the page on a real iPhone:
Swipe vertically starting on the table and on the carousel. The page should move.
Swipe horizontally on them. Only the element should move.
Open and close the drawer, then scroll the page.
Scroll to the end of the drawer list and keep swiping. The page behind should stay put.
Safari has supported overscroll-behavior since version 16, so on older iPhones the drawer list can still chain to the page. Older iOS versions also ignored overflow: hidden on the body for touch scrolling, which is why some scroll-lock libraries use position: fixed on the body instead. If you support those devices, test there before picking a library.
Cart drawers deserve extra care here, since a drawer that freezes the store page costs sales. If you want to see a finished one, there's my cart drawer, and smaller pieces in micro-interactions. The free pieces come with their full source if you'd rather start from code.
The MDN and Tailwind pages that rank first for overscroll-behavior explain the property well, but they don't tell you which of these bugs you have. This is the order I check in:
Symptom
Likely cause
Fix
Page stops scrolling while the pointer is over a table
Wrapper is a vertical scroll container (fixed height, max-h, or 1px of overflow) plus overscroll-contain
Natural height, overscroll-x-contain only
Horizontal swipe at the end of a carousel goes back a page
Scroll chaining to the page on the x axis
overscroll-x-contain on the carousel
Scrolling a short menu moves the page behind it
Menu doesn't overflow, so contain does nothing
Scroll lock while the menu is open
Wheel over a chart never reaches the page
Native wheel listener with passive: false that always calls preventDefault()
Only cancel when zooming (Ctrl/Cmd)
Console says "Unable to preventDefault inside passive event listener"
e.preventDefault() in React's onWheel
Native listener with { passive: false }, used with care
Finger drag on a card scrolls the page halfway
No touch-action, or touch-action: none on a big element
touch-pan-y on the swipe area
Whole page frozen after closing a drawer
Scroll lock never removed
Counted lock, restored in effect cleanup
If more than one row fits, fix them top to bottom. The first two are CSS-only and take a minute.
I make animated React components at qodesign, and each one now goes through an automated scroll check before it ships.
It is a CSS property that controls what happens when a scroll container reaches its edge. The default, auto, passes the remaining scroll to the parent or page; contain keeps it inside the element; none also turns off the browser's overscroll effects like bounce and pull-to-refresh.
The property only applies while the element is a scroll container with content to scroll. If the content fits, the element can't scroll, so the gesture chains straight to the page. Lock the page scroll for modals and menus instead.
Add overscroll-behavior: contain (Tailwind overscroll-contain) to the inner element, so scroll stops chaining once it reaches the end. For a horizontal scroller, contain only the x axis so vertical page scroll keeps working.
Set overscroll-behavior-y: none on the html element. It disables the browser's pull-to-refresh and overscroll glow for the page, so only do it if your app gives users another way to refresh.