Cómo corregir el salto de scroll de la barra de dirección en navegadores móviles
El problema
Si alguna vez has construido un sitio animado por scroll y lo has probado en un dispositivo móvil real, lo has visto: cuando el usuario hace scroll hacia abajo y la barra del navegador se oculta, la página salta. Al volver arriba, la barra reaparece, y la página salta de nuevo, a veces en dirección contraria.
Nos encontramos con esto al construir el portfolio de Gros Gradient : una experiencia 3D impulsada por scroll donde una bola realiza un descenso de gradiente real sobre un paisaje matemático. La posición de la bola es una función pura de scrollProgress (un valor entre 0 y 1 derivado del scroll). Cuando la barra animaba, scrollProgress hacía picos, enviando la bola hacia adelante o hacia atrás sin ninguna acción del usuario.
Este artículo documenta cada hipótesis probada, cada callejón sin salida, y la solución que finalmente funcionó.
Entendiendo la barra de URL dinámica
En muchos navegadores móviles, la barra del navegador se oculta al hacer scroll hacia abajo y reaparece al volver arriba. Esto se llama el viewport dinámico.
El navegador expone tres unidades de altura de viewport:
| Unidad | Valor | ¿Cambia con la barra? |
|---|---|---|
100vh |
Variable según el navegador | A veces |
100dvh |
Altura dinámica | Sí : se actualiza durante la animación |
100svh |
Altura pequeña (barra visible) | No |
100lvh |
Altura grande (barra oculta) | No |
La barra en un iPhone moderno mide unos 83px. Cuando se oculta, window.innerHeight crece 83px. Cuando reaparece, decrece 83px, disparando visualViewport.resize en cada frame durante unos 300ms.
Lo que intentamos (y por qué no funcionó)
Intento 1 : clientHeight en lugar de innerHeight
Nuestro scrollProgress se calculaba así:
const maxScroll = document.body.scrollHeight - window.innerHeight
const scrollProgress = window.scrollY / maxScroll
Como window.innerHeight cambia con la barra, probamos document.documentElement.clientHeight, que se supone es el viewport de maquetación estable.
Resultado: Sin cambio. En iOS, clientHeight también cambia con la barra.
Intento 2 : Debounce de visualViewport.resize
Retrasamos el handler de resize 350ms para que el renderer no se redimensionara en cada frame de la animación.
Resultado: Empeoró las cosas. El retraso de 350ms introdujo un lag permanente.
Intento 3 : stableInnerHeight congelado al cargar
Capturamos window.innerHeight una sola vez al cargar (cuando la barra siempre está visible):
private stableInnerHeight = window.innerHeight
const maxScroll = document.body.scrollHeight - this.stableInnerHeight
Resultado: Corrigió parcialmente el movimiento invertido al subir. Pero el salto al bajar persistía.
Intento 4 : 100svh para las secciones
Los logs de debug revelaron que document.body.scrollHeight cambiaba por sí mismo : de 4225 a 4843 (618px de diferencia) cuando la barra se ocultaba. La causa: .section { height: 100vh } se recalculaba cuando 100dvh se actualizaba en el canvas.
Cambiamos a 100svh:
.section
height 100svh
height calc(var(--vh, 1vh) * 100) /* fallback */
Resultado: scrollHeight se estabilizó. Pero window.innerHeight en el denominador seguía moviéndose.
Intento 5 : Lerp de scrollY
Los logs mostraban que scrollY saltaba ~8px en el momento del snap : los navegadores móviles ajustan scrollY para compensar la aparición de la barra (scroll anchoring). Intentamos hacer lerp de scrollY sobre varios frames:
this.scrollY += (this.rawScrollY - this.scrollY) * 0.15
Resultado: Seguía saltando. El snap era demasiado grande.
Por qué todos estos enfoques fallaron
Cada enfoque intentaba compensar un objetivo en movimiento. La animación de la barra cambia window.innerHeight, document.body.scrollHeight, window.scrollY y dispara visualViewport.resize : todo simultáneamente, en cada frame, durante 300ms. La verdadera solución es evitar que la barra se oculte.
La solución que funciona
El navegador oculta la barra solo cuando document.body hace scroll. Si el documento nunca hace scroll, la barra nunca se oculta. El truco: mover el scroll a un elemento contenedor interno mientras se mantiene body con overflow: hidden.
Paso 1 : Bloquear el documento, hacer scroll en un contenedor interno
html, body {
height: 100%;
overflow: hidden; /* el documento nunca hace scroll → la barra nunca se oculta */
}
.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; /* los clics pasan al canvas */
}
.game {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh; /* simple 100vh : la barra nunca lo cambia */
z-index: 2; /* canvas encima, recibe todos los clics */
}
Paso 2 : Estructura HTML
<body>
<div class="game">
<canvas class="js-canvas"></canvas>
</div>
<div class="scroll-container">
<div class="scroll-content">
<!-- secciones, contenido SEO -->
</div>
</div>
</body>
Paso 3 : Reenviar eventos de scroll desde el canvas
Como .scroll-container tiene pointer-events: none, ya no recibe eventos wheel o touch. Los reenviamos manualmente desde el 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 })
}
Nota: touchstart debe estar en window, no en el canvas. Después de un clic raycast 3D, el canvas puede dejar de recibir touchstart, pero window siempre lo recibe.
Paso 4 : Leer el scroll desde el contenedor
// Antes
const maxScroll = document.body.scrollHeight - window.innerHeight
const scrollProgress = window.scrollY / maxScroll
// Después
const el = document.querySelector('.scroll-container')
const maxScroll = el.scrollHeight - el.clientHeight
const scrollProgress = el.scrollTop / maxScroll
el.clientHeight es la altura de un div fijo : nunca cambia. el.scrollTop es lo único que cambia, y solo cuando el usuario realmente hace scroll.
El bug oculto que descubrimos por el camino
Al depurar, descubrimos un bug en nuestra gestión del scroll lock de overlays. Un scrollRestoreHandler estaba registrado dos veces con opciones diferentes y eliminado solo una vez : dejando un listener fantasma que reseteaba scrollTop en cada scroll después de cerrar el overlay.
La solución: registrar cada listener exactamente una vez y eliminarlo con las mismas opciones:
// Registro único
scrollTarget.addEventListener('scroll', this.scrollRestoreHandler, { passive: true })
// Eliminación con las mismas opciones
scrollTarget.removeEventListener('scroll', this.scrollRestoreHandler, { passive: true })
Si el scroll se rompe después de cerrar un modal en tu sitio, comprueba primero los listeners no eliminados.
Por qué funciona de forma permanente
La barra se oculta porque el navegador detecta el scroll de document.body. Al mover todo el scroll a un div interno, la posición de scroll del documento se queda en 0 para siempre. El navegador nunca tiene razón para ocultar la barra.
scrollContainer.clientHeight, scrollContainer.scrollHeight y el 100vh en .game son todos perfectamente estables durante toda la vida de la página.
Resumen
| Enfoque | Resultado |
|---|---|
clientHeight en lugar de innerHeight |
Sin cambio |
Debounce de visualViewport.resize |
Empeoró las cosas |
stableInnerHeight congelado al cargar |
Corrección parcial |
Secciones en 100svh |
Estabiliza scrollHeight pero no el denominador |
Lerp de scrollY |
Insuficiente |
Contenedor scroll interno + overflow: hidden en body |
✅ Solución completa |
La lección: no intentes compensar la barra dinámica. Evita que se oculte manteniendo el scroll de document.body en cero.