← Accueil
EN · FR · DE · ES

Corriger le saut de scroll lié à la barre d'adresse des navigateurs mobiles

Mobile Three.js CSS Debugging

Le problème

Si vous avez déjà construit un site animé par le scroll et l'avez testé sur un vrai appareil mobile, vous l'avez vu : quand l'utilisateur fait défiler vers le bas et que la barre du navigateur se cache, la page saute. En remontant, la barre réapparaît, et la page saute à nouveau, parfois dans le mauvais sens.

Nous avons rencontré ce problème en construisant le portfolio Gros Gradient : une expérience 3D pilotée par le scroll où une balle effectue une vraie descente de gradient sur un paysage mathématique. La position de la balle est une fonction pure de scrollProgress (une valeur entre 0 et 1 dérivée du scroll). Quand la barre animait, scrollProgress faisait des pics, envoyant la balle en avant ou en arrière sans aucune action de l'utilisateur.

Cet article documente chaque hypothèse testée, chaque impasse, et la solution qui a finalement fonctionné.


Comprendre la barre d'URL dynamique

Sur de nombreux navigateurs mobiles, la barre du navigateur se cache en scrollant vers le bas et réapparaît en remontant. C'est ce qu'on appelle le viewport dynamique.

Le navigateur expose trois unités de hauteur de viewport :

Unité Valeur Change avec la barre ?
100vh Variable selon le navigateur Parfois
100dvh Hauteur dynamique Oui : se met à jour pendant l'animation
100svh Petite hauteur (barre visible) Non
100lvh Grande hauteur (barre cachée) Non

La barre sur un iPhone moderne fait environ 83px. Quand elle se cache, window.innerHeight augmente de 83px. Quand elle réapparaît, il diminue de 83px, en déclenchant visualViewport.resize à chaque frame pendant environ 300ms.


Ce que nous avons essayé (et pourquoi ça n'a pas marché)

Tentative 1 : clientHeight au lieu de innerHeight

Notre scrollProgress était calculé ainsi :

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

Puisque window.innerHeight change avec la barre, nous avons essayé document.documentElement.clientHeight, censé être le viewport de mise en page stable.

Résultat : Aucun changement. Sur iOS, clientHeight change aussi avec la barre.

Tentative 2 : Debounce de visualViewport.resize

Nous avons retardé le handler de resize de 350ms pour éviter que le renderer ne se redimensionne à chaque frame.

Résultat : Cela a empiré les choses. Le délai de 350ms a introduit un décalage permanent.

Tentative 3 : stableInnerHeight figé au chargement

Nous avons capturé window.innerHeight une seule fois au chargement (quand la barre est toujours visible) :

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

Résultat : Corrige partiellement le mouvement inversé en remontant. Mais le saut en descendant persistait.

Tentative 4 : 100svh pour les sections

Les logs de debug ont révélé que document.body.scrollHeight changeait lui-même : de 4225 à 4843 (618px de différence) quand la barre se cachait. La cause : .section { height: 100vh } se recalculait quand 100dvh se mettait à jour sur le canvas.

Nous avons basculé vers 100svh :

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

Résultat : scrollHeight s'est stabilisé. Mais window.innerHeight dans le dénominateur bougeait encore.

Tentative 5 : Lerp de scrollY

Les logs montraient que scrollY sautait lui-même de ~8px au moment du snap : les navigateurs mobiles ajustent scrollY pour compenser l'apparition de la barre (scroll anchoring). Nous avons essayé de lerp scrollY sur plusieurs frames :

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

Résultat : Saut toujours présent. Le snap était trop important.

Pourquoi toutes ces approches ont échoué

Chaque approche tentait de compenser une cible mouvante. L'animation de la barre change window.innerHeight, document.body.scrollHeight, window.scrollY et déclenche visualViewport.resize : tout simultanément, à chaque frame, pendant 300ms. La vraie solution est d'empêcher la barre de se cacher.


Le correctif qui fonctionne

Le navigateur cache la barre uniquement quand document.body défile. Si le document ne défile jamais, la barre ne se cache jamais. L'astuce : déplacer le scroll vers un élément conteneur interne tout en gardant body avec overflow: hidden.

Étape 1 : Verrouiller le document, scroller dans un conteneur interne

html, body {
    height: 100%;
    overflow: hidden; /* le document ne défile jamais → la barre ne se cache jamais */
}

.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; /* les clics passent au canvas */
}

.game {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh; /* simple 100vh : la barre ne le change jamais */
    z-index: 2; /* canvas au-dessus, reçoit tous les clics */
}

Étape 2 : Structure HTML

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

Étape 3 : Transmettre les événements de scroll depuis le canvas

Comme .scroll-container a pointer-events: none, il ne reçoit plus les événements wheel ou touch. On les transmet manuellement depuis le canvas :

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

Note : touchstart doit être sur window, pas sur le canvas. Après un clic raycast 3D, le canvas peut ne plus recevoir touchstart, mais window reçoit toujours.

Étape 4 : Lire le scroll depuis le conteneur

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

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

el.clientHeight est la hauteur d'un div fixe : il ne change jamais. el.scrollTop est la seule chose qui change, et uniquement quand l'utilisateur défile vraiment.


Le bug caché découvert en chemin

En déboguant, nous avons découvert un bug dans notre gestion du scroll lock des overlays de cards. Un scrollRestoreHandler était enregistré deux fois avec des options différentes et supprimé une seule fois : laissant un listener fantôme qui réinitialisait scrollTop à chaque scroll après la fermeture de l'overlay.

Le correctif : enregistrer chaque listener exactement une fois et le supprimer avec les mêmes options :

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

// Suppression avec les mêmes options
scrollTarget.removeEventListener('scroll', this.scrollRestoreHandler, { passive: true })

Si le scroll se casse après la fermeture d'une modale sur votre site, vérifiez d'abord les listeners non supprimés.


Pourquoi ça fonctionne de façon permanente

La barre se cache parce que le navigateur détecte le scroll de document.body. En déplaçant tout le scroll vers un div interne, la position de scroll du document reste à 0 pour toujours. Le navigateur n'a plus jamais de raison de cacher la barre.

scrollContainer.clientHeight, scrollContainer.scrollHeight et le 100vh sur .game sont tous parfaitement stables pour toute la durée de vie de la page.


Résumé

Approche Résultat
clientHeight au lieu de innerHeight Aucun changement
Debounce de visualViewport.resize A empiré les choses
stableInnerHeight figé au chargement Correction partielle
Sections en 100svh Stabilise scrollHeight mais pas le dénominateur
Lerp de scrollY Insuffisant
Conteneur scroll interne + overflow: hidden sur body ✅ Correctif complet

La leçon : ne pas essayer de compenser la barre dynamique. Empêcher qu'elle se cache en gardant le scroll de document.body à zéro.