Fixing the Mobile Browser Toolbar Scroll Jump
The Problem
If you have ever built a scroll-driven website and tested it on a real mobile device, you have seen it: as the user scrolls down and the browser toolbar hides, the page jumps. Scroll back up and the toolbar reappears, and the page jumps again, sometimes in the wrong direction.
We ran into this while building the Gros Gradient portfolio: a scroll-driven 3D experience where a ball performs actual gradient descent across a mathematical loss landscape. The ball's position is a pure function of scrollProgress (a 0–1 value derived from how far the user has scrolled). When the toolbar animates, scrollProgress would spike or dip, sending the ball lurching forward or backward with no user input.
This post documents every hypothesis we tested, every dead end, and the solution that finally worked. If you are searching for this fix, you are in the right place.
Understanding the Dynamic URL Bar
On many mobile browsers, the toolbar (address bar + navigation buttons) hides as the user scrolls down and reappears as they scroll up. This is called the dynamic viewport.
The browser exposes three viewport height units:
| Unit | Value | Changes with toolbar? |
|---|---|---|
100vh |
Varies by browser | Sometimes |
100dvh |
Dynamic viewport height | Yes: updates as toolbar animates |
100svh |
Small viewport height | No: always toolbar-visible size |
100lvh |
Large viewport height | No: always toolbar-hidden size |
On a modern iPhone, the toolbar is about 83px tall. When it hides, window.innerHeight grows by 83px. When it reappears, it shrinks by 83px. This happens over roughly 300ms of animation, firing visualViewport.resize on every frame.
What We Tried (And Why It Did Not Work)
Attempt 1: clientHeight instead of innerHeight
Our scrollProgress was computed as:
const maxScroll = document.body.scrollHeight - window.innerHeight
const scrollProgress = window.scrollY / maxScroll
Since window.innerHeight changes with the toolbar, we tried document.documentElement.clientHeight instead, which is supposed to be the stable layout viewport.
Result: No change. On iOS, clientHeight also shifts with the toolbar.
Attempt 2: Debouncing visualViewport.resize
We debounced the resize handler to 350ms so the renderer would not resize on every toolbar animation frame.
Result: Made things worse. The 350ms delay introduced a permanent lag between the toolbar state and the renderer size.
Attempt 3: stableInnerHeight frozen at page load
We captured window.innerHeight once on load (when the toolbar is always visible) and used that as a fixed denominator:
private stableInnerHeight = window.innerHeight
const maxScroll = document.body.scrollHeight - this.stableInnerHeight
Result: Partially fixed the wrong-direction ball movement on scroll up. But scroll down still jumped.
Attempt 4: 100svh for section heights
Debug logging revealed that document.body.scrollHeight itself was changing, from 4225 to 4843 (a difference of 618px) when the toolbar hid. The cause: .section { height: 100vh } was reflowing when 100dvh updated on the .game canvas.
We switched sections to 100svh (small viewport, always toolbar-visible):
.section
height 100svh
height calc(var(--vh, 1vh) * 100) /* fallback */
Result: scrollHeight stabilized. But window.innerHeight in the denominator still shifted, so jumps remained.
Attempt 5: Lerping scrollY
The debug logs showed scrollY itself jumping by ~8px at the snap moment: mobile browsers adjust scrollY to compensate for the toolbar appearing (scroll anchoring). We tried lerping scrollY over several frames to absorb the snap:
this.scrollY += (this.rawScrollY - this.scrollY) * 0.15
Result: Still jumped. The lerp smoothed small movements but the snap was large enough to show through.
Why All These Approaches Failed
Every approach tried to compensate for a moving target. The toolbar animation changes window.innerHeight, document.body.scrollHeight, window.scrollY, and fires visualViewport.resize, all simultaneously, on every frame, for 300ms. Trying to stabilize any one of them leaves the others still moving.
The real fix is to prevent the toolbar from ever hiding.
The Fix That Works
The browser only hides the toolbar when document.body scrolls. If the document never scrolls, the toolbar never hides. The trick is to move scrolling to an inner container element while keeping body overflow hidden.
Step 1: Lock the document, scroll an inner container
html, body {
height: 100%;
overflow: hidden; /* document never scrolls → toolbar never hides */
}
.scroll-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
z-index: 1;
pointer-events: none; /* clicks pass through to canvas */
}
.game {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh; /* simple 100vh: toolbar never changes it */
z-index: 2; /* canvas on top, receives all clicks */
}
Step 2: HTML structure
<body>
<div class="game">
<canvas class="js-canvas"></canvas>
</div>
<div class="scroll-container">
<div class="scroll-content">
<!-- sections, SEO content -->
</div>
</div>
</body>
Step 3: Forward scroll events from canvas to container
Since .scroll-container has pointer-events: none, it no longer receives wheel or touch events. We forward them manually from the canvas:
const scrollContainer = document.querySelector<HTMLElement>('.scroll-container')
const canvas = document.querySelector<HTMLElement>('.game')
if (scrollContainer && canvas) {
// Wheel (desktop + some mobile)
canvas.addEventListener('wheel', (e) => {
const overlay = document.getElementById('card-overlay')
if (overlay?.classList.contains('active')) return
scrollContainer.scrollTop += e.deltaY
}, { passive: true })
// Touch: delta-based so state never goes stale after a tap
let lastTouchY = 0
window.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) return
lastTouchY = e.touches[0].clientY
}, { passive: true })
window.addEventListener('touchmove', (e) => {
if (e.touches.length !== 1) return
const overlay = document.getElementById('card-overlay')
if (overlay?.classList.contains('active')) return
const currentY = e.touches[0].clientY
const dy = lastTouchY - currentY
lastTouchY = currentY
scrollContainer.scrollTop += dy
}, { passive: true })
}
Note: touchstart must be on window, not the canvas. After a 3D raycast click activates an element, the canvas may stop receiving touchstart, but window always fires.
Step 4: Read scroll from the container
Replace all window.scrollY references in your scroll-progress logic:
// Before
const maxScroll = document.body.scrollHeight - window.innerHeight
const scrollProgress = window.scrollY / maxScroll
// After
const el = document.querySelector('.scroll-container')
const maxScroll = el.scrollHeight - el.clientHeight
const scrollProgress = el.scrollTop / maxScroll
el.clientHeight is the height of a fixed div: it never changes. el.scrollHeight is the total scrollable content height: also stable. el.scrollTop is the only thing that changes, and only when the user actually scrolls.
The Hidden Bug We Found Along The Way
While debugging, we discovered an unrelated bug in our card overlay scroll lock. When a card overlay opened, we attached a touchmove handler to document with { capture: true, passive: false } to prevent background scrolling:
document.addEventListener('touchmove', this.scrollLockHandler, {
capture: true,
passive: false
})
A scrollRestoreHandler was registered twice with different options and removed only once, leaving a ghost listener that reset scrollTop back to the overlay-open value on every scroll event after the overlay closed. Scroll appeared completely broken.
The fix: register each listener exactly once, and remove it with matching options:
// Register once
scrollTarget.addEventListener('scroll', this.scrollRestoreHandler, { passive: true })
// Remove with matching options
scrollTarget.removeEventListener('scroll', this.scrollRestoreHandler, { passive: true })
If scroll breaks after a modal or overlay closes on your site, check for unremoved event listeners first.
Why This Works Permanently
The toolbar hides because the browser detects document.body scrolling and reclaims screen real estate. By moving all scroll to an inner div, the document scroll position stays at 0 forever. The browser never has a reason to hide the toolbar.
No more:
window.innerHeightchanging mid-scrolldocument.body.scrollHeightreflowingscrollYadjusting for scroll anchoringvisualViewport.resizefiring 60 times per second
scrollContainer.clientHeight, scrollContainer.scrollHeight, and the 100vh on .game are all perfectly stable for the entire lifetime of the page.
Summary
| Approach | Result |
|---|---|
clientHeight instead of innerHeight |
No change: also shifts on iOS |
Debouncing visualViewport.resize |
Made things worse |
Frozen stableInnerHeight |
Partially fixed one direction |
100svh section heights |
Stabilized scrollHeight but not the denominator |
Lerping scrollY |
Insufficient: snap too large |
Inner scroll container + overflow: hidden on body |
✅ Fully fixed |
The lesson: do not try to compensate for the dynamic toolbar. Prevent it from hiding in the first place by keeping document.body scroll at zero.