← Inicio
EN · FR · DE · ES

Cómo corregir el salto de scroll de la barra de dirección en navegadores móviles

Mobile Three.js CSS Debugging

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.