← Home
EN · FR · DE · ES

Fixing the Mobile Browser Toolbar Scroll Jump

Mobile Three.js CSS Debugging

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:

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.