Corriger le saut de scroll lié à la barre d'adresse des navigateurs mobiles
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.