overscroll-behavior: Taming Nested Scrolling, Pull-to-Refresh and Back-Swipe
A dropdown list scrolls to its last option, the user keeps scrolling, and the entire page lurches down behind it. A chat panel reaches its oldest message, the user drags a little further, and the browser starts a pull-to-refresh that throws away their draft. A horizontal carousel hits its first slide, the user keeps swiping, and the browser navigates back a page. All three are the same mechanism — scroll chaining and overscroll gestures — and all three are fixed with one property. This page explains what happens at a scroll boundary and how overscroll-behavior controls it. It sits in Scroll Snap & Overflow Layouts within the Mastering Container Queries & Responsive Layouts guide.
Why the page moves when a panel reaches its end
Scrolling in browsers is hierarchical. When a scroll gesture starts over an element, the browser scrolls the nearest scroll container under the pointer. If that container is already at its limit in the gesture's direction, the browser does not discard the rest of the gesture; it chains it to the next scrollable ancestor. Eventually the chain reaches the root, and at the root the browser applies its own boundary effects: the rubber-band bounce on Apple platforms, the glow on Android, pull-to-refresh at the top of the page on mobile, and edge swipes for history navigation on some platforms.
This design is usually helpful. A reader scrolling an article with an embedded code block does not want the code block to trap their wheel. But for panels that are meant to be self-contained — menus, sheets, chat logs, maps, carousels — chaining turns the edge of the panel into a trapdoor.
The complete implementation
The demo isolates the mechanism. Two identical lists sit inside a scrolling panel; only the right-hand list has overscroll-behavior-y: contain. Scroll to the bottom of each list with a wheel or trackpad and keep scrolling.
The page below has three self-contained scrollers — a dropdown list, a chat log and a horizontal carousel — each with the appropriate setting, and a modal that locks the page behind it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>overscroll-behavior</title>
<style>
body { margin: 0; font: 15px/1.5 system-ui, sans-serif; }
/* A long dropdown: reaching its last option must not scroll the page. */
.menu {
max-height: 14rem;
overflow-y: auto;
overscroll-behavior-y: contain; /* keep the local bounce, stop chaining */
}
/* A chat log that users drag upward to load history: without this,
pulling past the oldest message triggers pull-to-refresh. */
.chat-log {
height: 60svh;
overflow-y: auto;
overscroll-behavior-y: contain;
}
/* A horizontal carousel: swiping past the first slide must not trigger
the browser's back navigation gesture. */
.carousel {
display: flex;
gap: 1rem;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
}
.carousel > * { flex: 0 0 80%; scroll-snap-align: start; }
/* Modal: stop chaining from the dialog body, and lock the page itself
while any modal dialog is open. :has() does it without script. */
.modal__body {
max-height: 70dvh;
overflow-y: auto;
overscroll-behavior: contain;
}
:root:has(dialog[open].modal) {
overflow: hidden;
/* Reserve the scrollbar's space so the page does not shift sideways
when the scrollbar disappears. */
scrollbar-gutter: stable;
}
/* Scrollable regions must be keyboard reachable and visibly focused. */
:is(.menu, .chat-log, .carousel, .modal__body):focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
</style>
</head>
<body>
<ul class="menu" tabindex="0" aria-label="Countries"><li>Albania</li><li>Algeria</li><li>Andorra</li></ul>
<div class="chat-log" tabindex="0" role="log" aria-label="Messages"><p>Oldest message</p></div>
<div class="carousel" tabindex="0" aria-label="Photos, scroll horizontally"><div>One</div><div>Two</div></div>
<dialog class="modal" aria-labelledby="m-title">
<h2 id="m-title">Terms</h2>
<div class="modal__body" tabindex="0">Long terms text…</div>
</dialog>
</body>
</html>
The modal lock deserves a note. overscroll-behavior on the dialog body prevents chaining from gestures that start inside the dialog, but a wheel over the backdrop starts on the page and scrolls it directly. Locking the root with overflow: hidden covers that case, and scrollbar-gutter: stable prevents the layout shift that occurs when the root scrollbar disappears. Using :root:has(dialog[open]) keeps the lock in sync with the dialog state with no script — a stable-argument :has() of the kind
The key technique: auto, contain and none
contain is the value to reach for almost every time. It stops the chain and keeps the platform's local boundary feedback, so users still feel the bounce that tells them they reached the end of the list. none removes that feedback too, which makes the edge feel dead; reserve it for surfaces where any boundary effect is wrong, such as a full-screen map or a canvas where dragging past the edge should do nothing at all.
The property has axis-specific longhands — overscroll-behavior-x, -y, and the logical -inline and -block — which matter more than they seem. A horizontal carousel should contain the horizontal axis, to stop back-swipe navigation, but not the vertical axis: a user who swipes vertically over the carousel is trying to scroll the page, and containing both axes would trap them.
Where containment is wrong
Containment is so often the fix that it is tempting to apply it to every scroll container. Resist that, because in-flow scroll regions inside a document usually should chain.
A wide table wrapped in overflow-x: auto, a long code block, a scrolling embed inside an article — these are parts of a page the reader is scrolling through. When the reader's wheel happens to pass over one while it is already at its end, chaining lets the page keep moving and the reading flow continues. Contain those regions and the wheel suddenly does nothing whenever the pointer lands on a code sample, which feels like the page has frozen. The guideline is: contain surfaces that are modal or self-contained — menus, dialogs, drawers, chat logs, maps, carousels — and leave inline regions of a document at auto.
There is one more case where the default matters: touch scrolling on long pages full of horizontally scrolling rows. With overscroll-behavior-x: contain on each row, a slightly diagonal vertical swipe that starts on a row still scrolls the page vertically, because only the horizontal axis is contained. Containing both axes on such rows would trap vertical swipes and make the page feel sticky. Axis-specific containment is what keeps these layouts comfortable on phones.
Variation: disabling pull-to-refresh for a specific surface
Some full-screen surfaces genuinely conflict with pull-to-refresh: a map, a whiteboard, a game, a chat that scrolls to load older content at the top. Setting overscroll-behavior-y on the root disables the gesture where the browser implements it as an overscroll effect.
/* Only on the full-screen map page, not site-wide. */
:root:has(body.map-page) {
overscroll-behavior-y: none;
}
/* The map itself should not bounce either. */
.map {
position: fixed;
inset: 0;
overscroll-behavior: none;
touch-action: none; /* the map handles pan and zoom itself */
}
Two cautions apply. First, scope it tightly — to one page type, or to a state such as an open editor — because users expect pull-to-refresh on ordinary content pages and removing it is a surprising regression. Second, touch-action: none hands every gesture to the page, so the map must implement its own panning and zooming, and browser pinch-zoom is disabled over it. That is appropriate for a map widget and inappropriate almost anywhere else, since pinch-zoom is an important accessibility tool.
Nested scrolling pairs naturally with the snapport properties covered in scroll-margin and scroll-padding, since a contained panel with a sticky internal header needs its own scroll-padding for focus scrolling inside it. And where a panel's content animates in as it scrolls, scroll-driven animations can attach to the panel's own scroll timeline rather than the page's.
Browser support
overscroll-behavior and its longhands are supported in all current versions of Chrome, Edge, Firefox and Safari. How none on the root interacts with pull-to-refresh and history-swipe gestures varies by platform, because those gestures are browser features rather than CSS features; test on the devices you support. scrollbar-gutter is supported in current Chromium and Firefox and in recent Safari. :has(), used for the scroll lock, is supported in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+.
FAQ
What is scroll chaining? When an inner scroll container reaches its end and the user keeps scrolling, the browser passes the remaining scroll to the nearest scrollable ancestor, usually the page. That hand-off is scroll chaining, and it is why scrolling to the bottom of a dropdown can suddenly move the whole page.
What is the difference between contain and none?
Both stop scroll chaining to ancestors. contain keeps the element's own boundary effects, such as the bounce or glow at the edge. none also removes those local effects, and on the root element it disables pull-to-refresh and edge-swipe navigation where the browser ties them to overscroll.
Does overscroll-behavior lock the page behind a modal?
Only partly. It stops scrolling that starts inside the modal from chaining to the page, but the page can still be scrolled directly, for example by a wheel over the backdrop. Combine it with overflow: hidden on the root while the modal is open, which :has() can apply without script.
Is it safe to disable pull-to-refresh? Only for surfaces where the gesture misfires, such as a full-screen map, a drawing canvas or a chat that scrolls upward. Users rely on pull-to-refresh on ordinary pages, so never disable it globally just to remove the bounce effect.
Related
- Scroll Snap & Overflow Layouts — the parent guide to scrolling layouts.
- CSS-Only Carousel With Scroll Snap — a contained horizontal scroller in full.
- Popover Attribute and CSS Styling — overlays whose inner lists need containment.
- Transitioning display With allow-discrete — animating the modals these rules lock behind.
Related articles
More pages in the same section.