wandres.dev
ELEMENTS III · Overlays de layout

Scroll y scroll-snap: quién desplaza y por qué no engancha

El distintivo de contenedor de scroll, el overlay de puntos de ajuste, y el procedimiento para encontrar quién está desplazándose realmente cuando no es quien crees.

⏱ 14 min

El scroll produce una familia de bugs con una característica común: el elemento que se desplaza casi nunca es el que crees. Un contenedor con overflow: auto que nadie recuerda haber puesto, un ancestro con height: 100% que no llega al viewport, un elemento con overflow: hidden que corta el contenido sin permitir moverlo. El panel marca los contenedores de scroll con un distintivo, y eso convierte una investigación de veinte minutos en un vistazo.

🎯 Al terminar esta lección sabrás
  • Identificar todos los contenedores de scroll de una página y cuál es el que se desplaza.
  • Diagnosticar por qué un contenedor no permite desplazarse aunque su contenido desborde.
  • Leer el overlay de scroll-snap y sus puntos de ajuste.
  • Explicar las tres razones por las que un ajuste de scroll no engancha donde debería.

Encontrar quién desplaza

Un elemento es contenedor de scroll cuando su overflow en algún eje vale auto, scroll, hidden o clip, y su contenido excede su caja. El panel marca esos elementos con un distintivo junto a su etiqueta en el árbol, y ese distintivo es la respuesta rápida.

La respuesta exhaustiva se saca de la consola.

// Todos los contenedores con desplazamiento real en la pagina
console.table(
  [...document.querySelectorAll('*')]
    .filter(el => el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1)
    .map(el => ({
      elemento: el.tagName + (el.id ? '#' + el.id : el.className ? '.' + String(el.className).split(' ')[0] : ''),
      overflowY: getComputedStyle(el).overflowY,
      visible: el.clientHeight,
      contenido: el.scrollHeight
    }))
);

Esa tabla incluye los contenedores con overflow: hidden, que desbordan pero no se pueden desplazar con la rueda, y son precisamente los que producen el bug de “el contenido está cortado y no puedo llegar a él”.

Para saber cuál se está desplazando en un momento dado, la vía es escuchar.

// Registra que elemento emite cada evento de scroll
document.addEventListener('scroll', e => console.log('scroll en', e.target), true);

El tercer argumento en true es imprescindible: los eventos de scroll de un elemento no se propagan hacia arriba, así que solo se pueden capturar en la fase de captura desde el documento. Este detalle es la razón de que mucha gente concluya erróneamente que “el scroll no emite eventos” cuando prueba a escucharlo en document sin captura.

⚠️
Cuidado

El scroll del documento se emite en document pero el elemento que hay que consultar para su posición es document.scrollingElement, no document.body. La diferencia depende del modo de renderizado y ha causado incompatibilidades históricas; usar scrollingElement es la forma correcta en todos los casos.

Por qué no se puede desplazar

Cuatro causas, en orden de frecuencia.

Un ancestro tiene overflow: hidden. El contenido se corta y no hay barra. Es el caso más común y el distintivo del árbol lo delata.

La altura del contenedor no está limitada. Para que haya desplazamiento tiene que haber un contenedor con altura definida y contenido mayor. Si el contenedor crece con su contenido, nunca desborda y el scroll ocurre en el documento. La cadena de height: 100% que hay que mantener desde html hasta el contenedor es frágil y se rompe con un solo eslabón; height: 100dvh en el contenedor o un layout de grid con 1fr son alternativas más robustas.

El evento se está cancelando. Un manejador de wheel o de touchmove con preventDefault bloquea el desplazamiento. Se detecta con getEventListeners sobre el elemento y sus ancestros, o con un breakpoint de evento en la categoría correspondiente.

overscroll-behavior o touch-action lo impiden. Menos frecuente, pero touch-action: none desactiva el desplazamiento táctil por completo y es fácil de heredar sin querer desde una librería de arrastre.

El overlay de scroll-snap

Un contenedor con scroll-snap-type declarado muestra su propio distintivo, y al activarlo el overlay dibuja las posiciones de ajuste: una línea por cada punto donde el scroll se detendrá, y una marca en el borde del contenedor que indica la alineación configurada.

Ver esas líneas responde directamente a la pregunta de por qué el ajuste engancha donde engancha. Y cuando no hay líneas dibujadas donde esperabas, el diagnóstico es igual de directo.

Las tres razones por las que el snap no engancha

Falta scroll-snap-align en los hijos. El contenedor declara scroll-snap-type y eso es solo la mitad: cada hijo que deba ser un punto de parada necesita scroll-snap-align con start, center o end. Sin él, no hay puntos y el contenedor se desplaza normal. Con el overlay activo, la ausencia de líneas lo confirma.

El elemento que ajusta no es hijo del contenedor de scroll. El ajuste opera entre un contenedor de scroll y sus descendientes, y si has puesto scroll-snap-type en un elemento que no es el que se desplaza, no pasa nada. Los dos distintivos —el de scroll y el de scroll-snap— tienen que estar en el mismo nodo, y comprobarlo es un vistazo.

El padding de scroll desplaza los puntos. scroll-padding en el contenedor y scroll-margin en los hijos modifican dónde caen las líneas de ajuste, y son la herramienta correcta cuando hay una cabecera fija que tapa el contenido. También son la causa de que los puntos aparezcan desplazados respecto a lo que esperabas, y el overlay muestra las líneas en su posición efectiva, no en la nominal.

Hay una cuarta causa que no es de CSS: si un script está llamando a scrollTo o a scrollIntoView en respuesta al scroll, compite con el motor de ajuste y el resultado es un forcejeo visible. Un breakpoint en esas llamadas lo confirma.

El scroll es el único layout que existe en dos sitios a la vez

Hay algo estructuralmente peculiar en el scroll que explica por qué produce tantos bugs difíciles: la posición de desplazamiento no es una propiedad de CSS, es estado del navegador, y ese estado se conserva de formas que tu código no controla. El navegador restaura la posición al volver atrás en el historial. La restaura al recargar. La ajusta cuando el contenido cambia de tamaño por encima del punto visible, mediante el anclaje de scroll, que es un mecanismo activo por defecto que la mayoría de la gente no sabe que existe y que puede pelearse con la lógica propia. Y en un contenedor con scroll-behavior: smooth, un scrollTo no termina cuando la función retorna sino unos cientos de milisegundos después, lo cual rompe cualquier código que mida la posición justo después de pedir un desplazamiento. Las consecuencias prácticas son tres. La primera es que medir después de desplazar requiere esperar, y la forma correcta es escuchar el evento scrollend, que es el que indica que el desplazamiento ha terminado de verdad, incluidos los suaves y los de ajuste. La segunda es que el anclaje de scroll puede ser tu enemigo: si tu lista carga contenido arriba y el navegador ajusta la posición para compensar, tu propio ajuste se suma al suyo y el salto es doble; overflow-anchor: none lo desactiva. Y la tercera, la que más tiempo ahorra en depuración: antes de investigar un bug de scroll, comprueba si el elemento que se desplaza es el que crees, porque en el momento en que hay dos contenedores de scroll anidados, la mitad de tus hipótesis se estaban aplicando al equivocado.