overscroll-behavior contain: Stop Scroll Stealing in React

Updated 8 min read

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.

On this page
  1. Why does overflow-x: auto also scroll vertically?
  2. overscroll-behavior contain vs overscroll-behavior-x contain
  3. Why doesn't overscroll-behavior: contain work on my element?
  4. Why doesn't preventDefault stop the wheel in React?
  5. touch-action: pan-y for swipe carousels
  6. Why won't the page scroll after closing a modal?
  7. Testing on iOS Safari
  8. Quick diagnosis table
  9. FAQ
  10. What is CSS overscroll-behavior?
  11. Why doesn't overscroll-behavior: contain work when the element doesn't overflow?
  12. How do I prevent the parent from scrolling when an inner div scrolls?
  13. How do I stop pull-to-refresh on mobile in a web app?

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.

Why does overflow-x: auto also scroll vertically?

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.

tsx
export default function ScrollTable({ rows }: { rows: string[][] }) {
  return (
    <div className="overflow-x-auto overscroll-x-contain rounded-xl border">
      <table className="min-w-[900px] text-left text-sm">
        <tbody>
          {rows.map((r, i) => (
            <tr key={i} className="border-t">
              {r.map((c, j) => (
                <td key={j} className="whitespace-nowrap px-4 py-2">
                  {c}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

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 contain vs overscroll-behavior-x contain

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.

So the rule I follow:

  • Horizontal scroller: overscroll-x-contain only.
  • Vertical panel (drawer, modal body, chat log): overscroll-contain.

A scroll-snap carousel is the same case as the table:

tsx
export default function Carousel({ items }: { items: string[] }) {
  return (
    <div className="flex snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain pb-2">
      {items.map((item) => (
        <div
          key={item}
          className="grid h-40 w-64 shrink-0 snap-start place-items-center rounded-xl bg-zinc-200"
        >
          {item}
        </div>
      ))}
    </div>
  );
}

No JavaScript is involved. Native scrolling handles wheel, trackpad and touch, and the page keeps its vertical scroll.

Why doesn't overscroll-behavior: contain work on my element?

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.

Why doesn't preventDefault stop the wheel in React?

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:

tsx
"use client";

export default function BadWheel() {
  return (
    <div
      className="h-40 overflow-auto rounded-xl border"
      onWheel={(e) => {
        // Doesn't work: React's wheel listener is passive.
        e.preventDefault();
      }}
    >
      <div className="h-96 p-4">Scroll me</div>
    </div>
  );
}

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.

touch-action: pan-y for swipe carousels

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:

tsx
return (
  <div
    // The browser keeps vertical panning (page scroll); we handle horizontal drags.
    className="touch-pan-y select-none overflow-hidden rounded-xl bg-zinc-200"
    onPointerDown={onPointerDown}
    onPointerMove={onPointerMove}
    onPointerUp={onPointerUp}
    onPointerCancel={onPointerUp}
  >
    <div className="grid h-40 place-items-center" style={{ transform: `translateX(${dx}px)` }}>
      {cards[index]}
    </div>
  </div>
);

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.

Why won't the page scroll after closing a modal?

Animated cart drawer sliding over a store page, a component that locks and restores page 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:

ts
"use client";

import { useEffect } from "react";

let locks = 0;
let saved = { overflow: "", paddingRight: "" };

export function useScrollLock(active: boolean) {
  useEffect(() => {
    if (!active) return;

    const root = document.documentElement;
    if (locks === 0) {
      saved = { overflow: root.style.overflow, paddingRight: root.style.paddingRight };
      const scrollbar = window.innerWidth - root.clientWidth;
      root.style.overflow = "hidden";
      if (scrollbar > 0) root.style.paddingRight = `${scrollbar}px`;
    }
    locks++;

    return () => {
      locks--;
      if (locks === 0) {
        root.style.overflow = saved.overflow;
        root.style.paddingRight = saved.paddingRight;
      }
    };
  }, [active]);
}

It also pads the root by the scrollbar width so the page doesn't jump sideways when the scrollbar disappears. The drawer just passes its open state:

tsx
export default function Drawer() {
  const [open, setOpen] = useState(false);
  useScrollLock(open);
  // ...

And the drawer's own panel uses overscroll-contain, so reaching the end of the item list doesn't scroll the page behind it:

tsx
<aside
  role="dialog"
  aria-modal="true"
  aria-label="Cart"
  className="h-full w-80 overflow-y-auto overscroll-contain bg-white p-6"
  onClick={(e) => e.stopPropagation()}
>

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.

Testing on iOS Safari

Desktop Chrome hides some of these. Before shipping, try the page on a real iPhone:

  1. Swipe vertically starting on the table and on the carousel. The page should move.
  2. Swipe horizontally on them. Only the element should move.
  3. Open and close the drawer, then scroll the page.
  4. 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.

Quick diagnosis table

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:

SymptomLikely causeFix
Page stops scrolling while the pointer is over a tableWrapper is a vertical scroll container (fixed height, max-h, or 1px of overflow) plus overscroll-containNatural height, overscroll-x-contain only
Horizontal swipe at the end of a carousel goes back a pageScroll chaining to the page on the x axisoverscroll-x-contain on the carousel
Scrolling a short menu moves the page behind itMenu doesn't overflow, so contain does nothingScroll lock while the menu is open
Wheel over a chart never reaches the pageNative 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 onWheelNative listener with { passive: false }, used with care
Finger drag on a card scrolls the page halfwayNo touch-action, or touch-action: none on a big elementtouch-pan-y on the swipe area
Whole page frozen after closing a drawerScroll lock never removedCounted 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.

FAQ

What is CSS overscroll-behavior?

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.

Why doesn't overscroll-behavior: contain work when the element doesn't overflow?

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.

How do I prevent the parent from scrolling when an inner div scrolls?

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.

How do I stop pull-to-refresh on mobile in a web app?

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.