Notebook-style illustration of scroll chaining and the overscroll-behavior property
Web & Browser APIs

Why Scrolling a Small Box Drags the Whole Page With It

Scroll chaining, and the one CSS line that refuses it.

When a scrollable element reaches its end, the browser does not stop. It hands the remaining scroll to the nearest scrollable ancestor. That handoff is called scroll chaining, and it is the default — nobody switched it on. overscroll-behavior is how you refuse it.

Key Components

↧ Scroll chaining

The default handoff. Inner box hits its bottom, leftover scroll travels up to the nearest scrollable ancestor — usually the page behind your modal.

◱ contain

Stops the handoff at this element's boundary. The page behind stays put. The native overscroll affordance (bounce, pull-to-refresh) still works.

⊘ none

contain plus suppression of the affordance itself — no rubber-band bounce, no pull-to-refresh gesture.

⇅ Per-axis

Both values are per-axis. overscroll-behavior-y is the common case: a drawer or chat panel that scrolls vertically.

How It Works

ValueChainingOverscroll affordance
autoYes — the defaultKept
containBlocked at the boundaryKept
noneBlocked at the boundarySuppressed
  1. You drag inside a scrollable inner box — a modal body, a drawer, a chat panel.
  2. The inner box reaches its scroll end. There is delta left over.
  3. Default: the browser propagates that leftover delta to the nearest scrollable ancestor. The page behind moves. Your modal now floats over shifting content.
  4. With overscroll-behavior-y: contain: propagation stops at the boundary. The leftover delta is discarded and the page holds still.

What this replaces

The old fix was a scroll or touchmove listener calling preventDefault(). That fights the compositor and drops frames, because it runs on the main thread — the browser must wait to hear whether you'll cancel before it can scroll. overscroll-behavior is declarative and handled where scrolling already lives, off the main thread.

Where It Bites

  • Modals and dialogs — scroll to the bottom of the dialog body, keep dragging, and the page beneath scrolls away underneath the overlay.
  • Side drawers and nav panels — a long menu that hands its overflow to the document once you hit the last item.
  • Chat panels and comment threads — reaching the top of the history yanks the whole page down mid-read.
  • Mobile pull-to-refresh — a full-page container set to none silently kills a gesture the OS provides and you cannot re-implement.

Two ways this misleads you

1. none removes pull-to-refresh. On mobile that is a gesture users expect; suppressing it on a full-page container breaks an affordance you did not build and cannot replace. Reach for contain unless you specifically want the bounce gone.
2. contain only stops chaining at a boundary. It does not make an element scrollable. If the inner element has no overflow, there is no boundary and nothing to contain — which is exactly why the property so often appears to do nothing at all.

Test it in thirty seconds

Scroll to the exact bottom of the inner box, then keep dragging. With chaining, the page moves. With contain, it does not. If nothing changes either way, your inner element probably isn't scrolling in the first place — check that it actually overflows.

Checkpoint — answer before you move on

  1. A colleague sets overscroll-behavior: contain on a modal and reports no change at all. What is the first thing you check, and why is the property innocent? Hint: containment needs a boundary to contain.
  2. State the difference between contain and none in one sentence, then name the concrete cost of choosing none on a full-page container. Hint: one blocks a handoff; the other also blocks a gesture.
  3. Why is a preventDefault() scroll listener worse than this CSS property even when both produce the same visible result? Hint: think about which thread each one runs on, and what the browser must wait for.