← Startseite
EN · FR · DE · ES

Den Adressleisten-Scroll-Sprung in mobilen Browsern beheben

Mobile Three.js CSS Debugging

Das Problem

Wer schon einmal eine scroll-gesteuerte Website gebaut und auf einem echten Mobilgerät getestet hat, kennt es: Wenn der Nutzer nach unten scrollt und die Browser-Leiste verschwindet, springt die Seite. Beim Hochscrollen erscheint die Leiste wieder, und die Seite springt erneut, manchmal in die falsche Richtung.

Wir sind auf dieses Problem beim Bauen des Gros Gradient Portfolios gestoßen : einer scroll-gesteuerten 3D-Erfahrung, bei der eine Kugel einen echten Gradientenabstieg über eine mathematische Verlustlandschaft durchführt. Die Position der Kugel ist eine reine Funktion von scrollProgress (ein Wert zwischen 0 und 1, abgeleitet vom Scroll). Wenn die Leiste animierte, sprang scrollProgress, was die Kugel ohne jede Nutzereingabe vor- oder zurückschnellen ließ.

Dieser Artikel dokumentiert jede getestete Hypothese, jede Sackgasse und die Lösung, die schließlich funktioniert hat.


Die dynamische URL-Leiste verstehen

Bei vielen mobilen Browsern versteckt sich die Browser-Leiste beim Runterscrollen und erscheint beim Hochscrollen wieder. Dies nennt sich dynamisches Viewport.

Der Browser stellt drei Viewport-Höheneinheiten bereit:

Einheit Wert Ändert sich mit der Leiste?
100vh Browserabhängig Manchmal
100dvh Dynamische Höhe Ja : aktualisiert sich während der Animation
100svh Kleine Höhe (Leiste sichtbar) Nein
100lvh Große Höhe (Leiste verborgen) Nein

Die Leiste auf einem modernen iPhone ist etwa 83px hoch. Wenn sie verschwindet, wächst window.innerHeight um 83px. Wenn sie erscheint, schrumpft es um 83px, dabei wird visualViewport.resize auf jedem Frame für ca. 300ms ausgelöst.


Was wir versucht haben (und warum es nicht funktioniert hat)

Versuch 1 : clientHeight statt innerHeight

Unser scrollProgress wurde so berechnet:

const maxScroll = document.body.scrollHeight - window.innerHeight
const scrollProgress = window.scrollY / maxScroll

Da window.innerHeight sich mit der Leiste ändert, probierten wir document.documentElement.clientHeight, das eigentlich der stabile Layout-Viewport sein soll.

Ergebnis: Keine Änderung. Auf iOS ändert sich auch clientHeight mit der Leiste.

Versuch 2 : Debounce von visualViewport.resize

Wir verzögerten den Resize-Handler um 350ms, damit der Renderer sich nicht bei jedem Frame der Animation neu dimensioniert.

Ergebnis: Es wurde schlimmer. Die 350ms Verzögerung führte zu einem dauerhaften Lag.

Versuch 3 : stableInnerHeight beim Laden eingefroren

Wir erfassten window.innerHeight einmalig beim Laden (wenn die Leiste immer sichtbar ist):

private stableInnerHeight = window.innerHeight
const maxScroll = document.body.scrollHeight - this.stableInnerHeight

Ergebnis: Behebt teilweise die falsche Richtung beim Hochscrollen. Aber der Sprung beim Runterscrollen blieb.

Versuch 4 : 100svh für Sektionen

Debug-Logs zeigten, dass document.body.scrollHeight sich selbst änderte : von 4225 auf 4843 (618px Unterschied) wenn die Leiste verschwand. Die Ursache: .section { height: 100vh } wurde neu berechnet, wenn 100dvh auf dem Canvas aktualisiert wurde.

Wir wechselten zu 100svh:

.section
    height 100svh
    height calc(var(--vh, 1vh) * 100) /* Fallback */

Ergebnis: scrollHeight stabilisierte sich. Aber window.innerHeight im Nenner bewegte sich noch.

Versuch 5 : Lerp von scrollY

Die Logs zeigten, dass scrollY beim Snap-Moment um ~8px sprang : mobile Browser passen scrollY an, um das Erscheinen der Leiste zu kompensieren (Scroll-Anchoring). Wir versuchten, scrollY über mehrere Frames zu lerpen:

this.scrollY += (this.rawScrollY - this.scrollY) * 0.15

Ergebnis: Sprung noch vorhanden. Der Snap war zu groß.

Warum all diese Ansätze scheiterten

Jeder Ansatz versuchte, ein bewegliches Ziel zu kompensieren. Die Leisten-Animation ändert window.innerHeight, document.body.scrollHeight, window.scrollY und löst visualViewport.resize aus : alles gleichzeitig, auf jedem Frame, für 300ms. Die echte Lösung ist, das Verstecken der Leiste zu verhindern.


Die Lösung, die funktioniert

Der Browser versteckt die Leiste nur, wenn document.body scrollt. Wenn das Dokument nie scrollt, versteckt sich die Leiste nie. Der Trick: Den Scroll zu einem inneren Container-Element verlagern, während body mit overflow: hidden gesetzt bleibt.

Schritt 1 : Dokument sperren, in einem inneren Container scrollen

html, body {
    height: 100%;
    overflow: hidden; /* Dokument scrollt nie → Leiste versteckt sich nie */
}

.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; /* Klicks gehen durch zum Canvas */
}

.game {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh; /* einfaches 100vh : die Leiste ändert es nie */
    z-index: 2; /* Canvas oben, empfängt alle Klicks */
}

Schritt 2 : HTML-Struktur

<body>
    <div class="game">
        <canvas class="js-canvas"></canvas>
    </div>
    <div class="scroll-container">
        <div class="scroll-content">
            <!-- Sektionen, SEO-Inhalt -->
        </div>
    </div>
</body>

Schritt 3 : Scroll-Events vom Canvas weiterleiten

Da .scroll-container pointer-events: none hat, empfängt es keine Wheel- oder Touch-Events mehr. Wir leiten sie manuell vom Canvas weiter:

const scrollContainer = document.querySelector<HTMLElement>('.scroll-container')
const canvas = document.querySelector<HTMLElement>('.game')

if (scrollContainer && canvas) {
    canvas.addEventListener('wheel', (e) => {
        const overlay = document.getElementById('card-overlay')
        if (overlay?.classList.contains('active')) return
        scrollContainer.scrollTop += e.deltaY
    }, { passive: true })

    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 })
}

Hinweis: touchstart muss auf window sein, nicht auf dem Canvas. Nach einem 3D-Raycast-Klick empfängt der Canvas möglicherweise kein touchstart mehr, aber window empfängt es immer.

Schritt 4 : Scroll vom Container lesen

// Vorher
const maxScroll = document.body.scrollHeight - window.innerHeight
const scrollProgress = window.scrollY / maxScroll

// Nachher
const el = document.querySelector('.scroll-container')
const maxScroll = el.scrollHeight - el.clientHeight
const scrollProgress = el.scrollTop / maxScroll

el.clientHeight ist die Höhe eines fixen div : sie ändert sich nie. el.scrollTop ist das Einzige, das sich ändert, und nur wenn der Nutzer wirklich scrollt.


Der versteckte Bug, den wir unterwegs entdeckten

Beim Debuggen entdeckten wir einen Bug in unserer Overlay-Scroll-Lock-Verwaltung. Ein scrollRestoreHandler war zweimal mit unterschiedlichen Optionen registriert und nur einmal entfernt : was einen Geister-Listener hinterließ, der scrollTop nach dem Schließen des Overlays bei jedem Scroll-Event zurücksetzte.

Die Lösung: jeden Listener genau einmal registrieren und mit den gleichen Optionen entfernen:

// Einmalige Registrierung
scrollTarget.addEventListener('scroll', this.scrollRestoreHandler, { passive: true })

// Entfernung mit gleichen Optionen
scrollTarget.removeEventListener('scroll', this.scrollRestoreHandler, { passive: true })

Wenn der Scroll nach dem Schließen eines Modals auf Ihrer Website nicht mehr funktioniert, prüfen Sie zuerst nicht entfernte Event-Listener.


Warum es dauerhaft funktioniert

Die Leiste versteckt sich, weil der Browser das Scrollen von document.body erkennt. Indem wir den gesamten Scroll zu einem inneren div verlagern, bleibt die Scroll-Position des Dokuments für immer bei 0. Der Browser hat nie einen Grund, die Leiste zu verstecken.

scrollContainer.clientHeight, scrollContainer.scrollHeight und das 100vh auf .game sind alle für die gesamte Lebensdauer der Seite vollkommen stabil.


Zusammenfassung

Ansatz Ergebnis
clientHeight statt innerHeight Keine Änderung
Debounce von visualViewport.resize Wurde schlimmer
stableInnerHeight beim Laden eingefroren Teilweise Korrektur
Sektionen in 100svh Stabilisiert scrollHeight, aber nicht den Nenner
Lerp von scrollY Unzureichend
Innerer Scroll-Container + overflow: hidden auf body ✅ Vollständige Lösung

Die Lektion: Versuchen Sie nicht, die dynamische Leiste zu kompensieren. Verhindern Sie, dass sie sich versteckt, indem Sie den Scroll von document.body bei null halten.