Native Overscroll Bounce in WebView Apps, iOS and Android
Get native overscroll in a WebView app with pure CSS: rubber-band on iOS, stretch on Android. Scroll an inner container, not the body, no native code.

The thing that makes a wrapped web app feel like a website is usually the scroll. Drag past the top of a real native screen and it stretches and springs back. Drag past the top of a web app that scrolls the body and you get a dead stop, the fixed header dragging with the page, and a flash of the WebView background underneath. The fix is not native code. It is one decision about which element scrolls.
Scroll an inner container instead of the body, and iOS and Android each draw their own native edge effect for free. Same CSS, both platforms.
Why this works at all
Despia ships your web app inside the real platform WebView, WKWebView on iOS and the Chromium-based WebView on Android. Those are the same engines the OS uses everywhere else, so they already know how to draw a rubber-band bounce or an edge stretch. You do not implement the effect. You just have to hand the scroll to an element the WebView will treat as a scroll port, and stop the body from eating the gesture first.
The body is the problem. When the document body scrolls, the WebView applies the overscroll to the whole viewport, which drags anything you positioned as fixed chrome and exposes the background behind the page. Move the scroll into a child container and the effect stays where it belongs.
Drop-in CSS
/* body never scrolls */
html, body { margin: 0; height: 100%; overflow: hidden; overscroll-behavior: none; }
/* one-viewport app shell */
#app { height: 100dvh; display: flex; flex-direction: column; }
/* fixed chrome, stays put, owns the safe areas */
.app-header { flex: none; padding-top: calc(0.5rem + var(--safe-area-top, env(safe-area-inset-top, 0px))); }
.tab-bar { flex: none; padding-bottom: calc(0.5rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px))); }
/* the scroller, this is where overscroll lives */
.scroll-container {
flex: 1;
min-height: 0; /* required in flex, or it collapses */
overflow-y: auto;
overscroll-behavior-y: contain; /* keep the bounce, stop chaining to body */
}
<div id="app">
<header class="app-header">...</header>
<main class="scroll-container"><!-- all scrollable content --></main>
<nav class="tab-bar">...</nav>
</div>
Three lines carry the whole thing. overflow: hidden on the body kills the body scroll. 100dvh on the shell measures against the dynamic viewport so the layout is right whether or not the URL bar is showing. min-height: 0 on the scroller is the one people forget: a flex child defaults to min-height: auto, which refuses to shrink below its content, so the container never actually scrolls and you spend an hour wondering why.
The chrome padding uses Despia's runtime insets, not bare env(). Inside a Despia app the runtime injects --safe-area-top and --safe-area-bottom, and that is the source of truth. The env(safe-area-inset-*) value is the fallback for web preview before the runtime variables exist, and the 0px at the end keeps the calc() valid when neither resolves. Write bare env() on its own and your header will sit under the notch in preview and jump once the app loads.
What each platform draws
Same CSS, one native effect per OS, none of it written by you.
| Platform | Effect |
|---|---|
| iOS, WKWebView | Rubber band, stretch past the edge and spring back |
| Android 12 and up | Stretch overscroll, content stretches then snaps |
| Android 11 and below | Edge glow, a glow at the edge with the content held still |
The edge effect is drawn by the OS, not by CSS, so it looks native on each platform but is not pixel-identical across them. That is correct, not a bug. A user on Android 13 should see the Android stretch, not an iOS bounce faked in JavaScript.
The one setting that quietly kills it
overscroll-behavior-y has two values people mix up.
contain stops the scroll from chaining to the parent but keeps the local overscroll affordance, so you still get the bounce or the stretch. That is what you want on the inner container.
none also suppresses the overscroll effect itself. Set none on your scroller by reflex and the native bounce disappears, then you assume the WebView does not support it and go looking for a native plugin that does not exist. Use none on the body to lock it down, contain on the scroller to keep the feel.
Short pages will not overscroll
There is nothing to spring back from if the content fits on screen, so a short page shows no effect. That is expected behaviour, not a broken setup. If you want the bounce to be reachable even on a short screen, give the content a hair more height than the viewport:
.scroll-container > .content { min-height: calc(100% + 1px); }
Add this to your app
Native scroll physics is a CSS decision in a Despia app, not a native project. The same web codebase that runs in your browser ships to the App Store and Google Play, and the WebView hands you the platform overscroll for free.





