scroll-snap como sistema: contenedor, hijos y parada
Las tres piezas que hay que declarar y dónde va cada una, la diferencia entre mandatory y proximity, y scroll-snap-stop para que no se salte elementos.
scroll-snap no es una propiedad, es un sistema de al menos dos declaraciones que viven en elementos distintos: una en el contenedor que dice en qué eje se ajusta y con qué rigidez, y otra en cada hijo que dice por qué punto se alinea. Olvidar la segunda es el error más frecuente y produce un contenedor que no ajusta nada. Y hay una tercera pieza, scroll-snap-stop, que decide si un gesto rápido puede saltarse elementos.
- Declarar el ajuste en el contenedor con eje y rigidez correctos.
- Elegir el punto de alineación de cada hijo.
- Evitar que un gesto rápido se salte elementos con
scroll-snap-stop. - Reconocer los dos fallos que hacen inalcanzable parte del contenido.
Las tres piezas
En el contenedor: scroll-snap-type. Toma un eje y, opcionalmente, una rigidez.
.carrusel {
overflow-x: auto;
scroll-snap-type: x mandatory;
}
Los ejes son x, y, block, inline y both. Los dos lógicos son los que quieres si la interfaz se traduce a modos de escritura distintos. both ajusta en los dos ejes a la vez y es raro fuera de un lienzo bidimensional.
La rigidez es mandatory o proximity. Con mandatory, el contenedor siempre acaba en un punto de ajuste: al soltar, salta al más cercano, sin excepción. Con proximity, solo ajusta si el punto está razonablemente cerca; si no, deja la posición libre. El valor por defecto cuando no lo escribes es proximity.
En los hijos: scroll-snap-align. Sin ella no hay puntos de ajuste y el sistema no hace nada.
.carrusel > * { scroll-snap-align: start; }
Los valores son none, start, end y center, y se pueden dar dos —el primero para el eje de bloque, el segundo para el de línea—. El valor indica qué borde del hijo se alinea con el borde correspondiente del contenedor: start alinea el borde inicial del hijo con el inicial del scrollport, center alinea los centros.
En los hijos, opcionalmente: scroll-snap-stop. Con always, el navegador no puede pasar de largo ese elemento en un solo gesto.
.carrusel > * { scroll-snap-stop: always; }
Sin ella, el valor por defecto es normal y un deslizamiento rápido puede recorrer varios elementos y ajustar en el que quede cerca al final. Con always, cada gesto avanza exactamente un elemento. Es lo que quieres en un carrusel de pantalla completa, donde saltarse una tarjeta significa saltarse contenido. Está en Chrome 75, Firefox 103 y Safari 15.
El carrusel completo
<div class="carrusel">
<article>Uno</article>
<article>Dos</article>
<article>Tres</article>
</div>
.carrusel {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: inline mandatory;
scroll-padding-inline: 1rem; /* margen interior de los puntos de ajuste */
overscroll-behavior-inline: contain; /* no arrastra el scroll de la página */
scrollbar-width: thin;
}
.carrusel > article {
flex: 0 0 min(80%, 24rem);
scroll-snap-align: start;
scroll-snap-stop: always;
}
Cinco declaraciones en el contenedor y tres en los hijos, y ni una línea de JavaScript. Comparado con cualquier biblioteca de carruseles, lo que se pierde es la paginación con botones —que se resuelve con scrollBy()— y lo que se gana es el comportamiento nativo del gesto, con su inercia y su goma, que ninguna biblioteca reproduce bien.
scroll-padding-inline merece atención: define un margen interior en el scrollport a efectos de ajuste, de modo que el elemento ajustado no queda pegado al borde. Es la propiedad hermana de scroll-margin, que hace lo mismo pero declarada en el hijo, y las dos tienen una lección propia al final de este nivel.
Los dos fallos que dejan contenido inalcanzable
Fallo 1: un hijo más grande que el contenedor con mandatory. Si un elemento mide más que el scrollport y el ajuste es obligatorio, el usuario no puede detenerse en la mitad de ese elemento: al soltar, el contenedor salta al punto de ajuste del principio o del final. La parte central queda inaccesible con gestos, y solo se llega con las teclas de flecha.
La corrección es proximity cuando los elementos pueden ser más grandes que la ventana, o asegurarse de que nunca lo son.
/* seguro para contenido de altura variable */
.secciones { scroll-snap-type: y proximity; }
Fallo 2: mandatory en el documento entero con contenido dinámico. Cuando se declara scroll-snap-type: y mandatory en html y el contenido cambia de altura —una imagen que carga tarde, un acordeón que se abre— el navegador reajusta al punto más cercano y la página salta bajo el dedo del usuario. Es un caso conocido de degradación de la experiencia y también de la métrica de estabilidad visual.
La corrección es la misma: proximity para el documento, mandatory solo para contenedores acotados cuyo contenido no cambia de tamaño.
flowchart TB
A[Quieres ajuste de scroll] --> B{Los elementos caben siempre en el scrollport}
B -->|Si| C{Es un carrusel o una lista paginada}
C -->|Si| D[mandatory mas snap-align start mas snap-stop always]
C -->|No| E[proximity mas snap-align start]
B -->|No, hay elementos mas altos que la ventana| F[proximity obligatoriamente]
F --> G[Con mandatory el centro del elemento seria inalcanzable]
A --> H{El contenedor es el documento entero}
H -->|Si| I[Evita mandatory si el contenido cambia de altura]
style A fill:#89b4fa,color:#11111b
style D fill:#a6e3a1,color:#11111b
style E fill:#a6e3a1,color:#11111b
style G fill:#f38ba8,color:#11111b
style I fill:#f9e2af,color:#11111bControlarlo desde JavaScript
El ajuste convive bien con el desplazamiento programático. scrollIntoView() con behavior: 'smooth' respeta los puntos de ajuste, y scrollBy() es la forma correcta de implementar los botones de paginación.
const carrusel = document.querySelector('.carrusel');
const paso = () => carrusel.querySelector('article').getBoundingClientRect().width + 16;
document.querySelector('#siguiente').addEventListener('click', () => {
carrusel.scrollBy({ left: paso(), behavior: 'smooth' });
});
Y hay dos eventos específicos del ajuste, scrollsnapchanging y scrollsnapchange, que avisan de qué elemento va a quedar ajustado y de cuál ha quedado. Son útiles para sincronizar indicadores de paginación sin observar la posición de scroll. Su soporte es más reciente que el del resto del sistema, así que compruébalo antes de depender de ellos y ten preparado un IntersectionObserver como alternativa, que funciona en todas partes.
La confusión que más problemas causa es leer mandatory como “el usuario está obligado a ir de elemento en elemento”. No es eso. La restricción no se aplica al gesto, que es completamente libre, sino a la posición de reposo: cuando el scroll termina, el contenedor tiene que estar en un punto de ajuste. Y ese matiz es exactamente el que hace peligroso el caso del elemento demasiado grande: no es que el navegador impida al usuario mirar el centro del elemento, es que no existe ninguna posición de reposo válida ahí, así que en cuanto el dedo se levanta la vista se va. Entendido así, la regla de diseño se deduce sola en lugar de memorizarse: el conjunto de puntos de ajuste tiene que cubrir todo el contenido que el usuario necesita poder mirar en reposo. Un carrusel de tarjetas lo cumple porque cada tarjeta cabe entera. Una lista de secciones largas no lo cumple, y por eso ahí mandatory es siempre un error. Y de la misma idea sale la razón por la que mandatory en el documento entero es tan mala idea con contenido que cambia de altura: cada vez que el contenido se mueve, la posición actual deja de ser un punto de ajuste válido, y el navegador está obligado por especificación a corregirla. El salto no es un bug del navegador, es la restricción que tú declaraste haciendo su trabajo.
- Declara
scroll-snap-typesinscroll-snap-alignen los hijos y comprueba que no ajusta nada. - Construye el carrusel completo y compara el gesto con y sin
scroll-snap-stop: always. - Pon un elemento el doble de alto que el scrollport con
y mandatorye intenta detenerte en su centro. - Cambia a
proximityy comprueba que ahora sí puedes. - Declara
mandatoryen el documento y carga una imagen sin dimensiones a mitad de la página; observa el salto.