wandres.dev
SCROLL · Snap, overscroll y barras

scroll-behavior y la preferencia de movimiento reducido

Qué hace exactamente scroll-behavior, cuándo lo respeta el navegador, y por qué declararlo sin condicionarlo a prefers-reduced-motion es un problema real de accesibilidad.

⏱ 16 min

scroll-behavior: smooth es una de las declaraciones más satisfactorias de escribir y una de las que más daño hace cuando se escribe mal. Convierte cada salto de scroll en una animación, incluidos los que el usuario no pidió, y para una parte de la población el desplazamiento animado no es un detalle estético: provoca mareo. La declaración correcta ocupa cuatro líneas en vez de una, y la diferencia importa.

🎯 Al terminar esta lección sabrás
  • Explicar qué desplazamientos afecta scroll-behavior y cuáles no.
  • Condicionar el desplazamiento suave a la preferencia del usuario.
  • Elegir entre la declaración CSS y el argumento de las APIs de scroll.
  • Coordinar el desplazamiento suave con una cabecera fija.

Qué afecta y qué no

scroll-behavior se declara en un contenedor de scroll —incluido el elemento raíz— y define cómo se realizan los desplazamientos provocados por la navegación o por la API, no los del usuario.

html { scroll-behavior: smooth; }

Afecta a:

  • La navegación a un fragmento, es decir, pulsar un enlace #seccion.
  • element.scrollIntoView() sin argumentos.
  • window.scrollTo(), scrollBy() y element.scroll() cuando no especifican behavior.
  • El desplazamiento al restaurar la posición al volver atrás en el historial.
  • El desplazamiento por teclado en algunos motores.

No afecta al desplazamiento con la rueda del ratón, el trackpad ni el gesto táctil. Esos los controla el sistema operativo y el navegador, y CSS no los toca.

Las APIs aceptan un behavior explícito que gana sobre la declaración CSS:

elemento.scrollIntoView({ behavior: 'smooth', block: 'start' });
window.scrollTo({ top: 0, behavior: 'instant' });   // fuerza el salto seco

Los tres valores de behavior en la API son auto —usa lo que diga el CSS—, smooth y instant. En CSS, los valores de la propiedad son auto y smooth; no existe instant como valor de la propiedad.

La preferencia de movimiento reducido

prefers-reduced-motion: reduce es la señal que el usuario emite desde los ajustes del sistema. En macOS es “Reducir movimiento”, en Windows “Mostrar animaciones”, en Android e iOS opciones equivalentes. Quien la activa suele tener un motivo médico: trastornos vestibulares, migraña con aura, epilepsia fotosensible.

Un desplazamiento suave de una página entera es, en términos de estímulo visual, uno de los movimientos más agresivos que produce una interfaz: toda la superficie de la pantalla se mueve durante varias décimas de segundo. Es exactamente el tipo de movimiento que la preferencia pide evitar.

La declaración correcta invierte el orden por defecto:

/* por defecto, sin animación */
html { scroll-behavior: auto; }

/* suave solo si el usuario no ha pedido lo contrario */
@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

Fíjate en que la consulta es no-preference y no reduce. La diferencia es importante: escribir el suave por defecto y anularlo con reduce deja sin proteger a los navegadores y sistemas que no exponen la preferencia, porque en ellos la consulta reduce no casa y el suave se queda aplicado. Con no-preference, el caso indeterminado cae del lado seguro.

En JavaScript, la comprobación equivalente:

const suave = window.matchMedia('(prefers-reduced-motion: no-preference)').matches;
elemento.scrollIntoView({ behavior: suave ? 'smooth' : 'instant', block: 'start' });
flowchart TB
A[Vas a declarar desplazamiento suave] --> B[Declara auto por defecto]
B --> C[Envuelve el smooth en prefers-reduced-motion no-preference]
C --> D{Tambien lo haces desde JavaScript}
D -->|Si| E[Consulta matchMedia y pasa instant cuando corresponda]
D -->|No| F[Listo]
A --> G[Nunca declares smooth sin condicion]
G --> H[El caso indeterminado quedaria sin proteger]
style A fill:#89b4fa,color:#11111b
style C fill:#a6e3a1,color:#11111b
style E fill:#a6e3a1,color:#11111b
style G fill:#f38ba8,color:#11111b
style H fill:#f38ba8,color:#11111b

Los detalles que se notan

La duración no se controla. No hay ninguna propiedad para ajustar cuánto dura el desplazamiento suave ni con qué curva. Cada motor usa su propia función, que además suele depender de la distancia. Si el diseño exige una duración concreta, hay que animar la posición a mano con requestAnimationFrame o con la API de animaciones, y en ese caso hay que reimplementar también la interrupción por gesto del usuario, que el nativo hace bien y una implementación manual casi nunca.

Se interrumpe. Un desplazamiento suave en curso se cancela en cuanto el usuario hace scroll manualmente. Es el comportamiento correcto y una de las cosas que más cuesta reproducir a mano.

Se combina con el ajuste. Si el contenedor tiene scroll-snap-type, el desplazamiento suave acaba en un punto de ajuste. Las dos características se coordinan sin que tengas que hacer nada.

Interactúa con la cabecera fija. La navegación a un fragmento coloca el destino en el borde superior del scrollport, que puede estar debajo de una cabecera pegajosa. La corrección no es tocar scroll-behavior, es scroll-padding-block-start en el contenedor de scroll, que trata la última lección de este nivel.

html {
  scroll-padding-block-start: 4.5rem;   /* la altura de la cabecera */
}

Cuidado con declararlo en html y en body a la vez. Solo uno de los dos es el contenedor de scroll del documento, y cuál lo sea depende del modo de renderizado. Declararlo en html es lo correcto; añadirlo también en body no ayuda y puede confundir al leer el código.

prefers-reduced-motion no dice quita la animación, dice quita el movimiento

Hay un malentendido extendido sobre esta consulta que conviene deshacer porque lleva a interfaces peores de lo necesario. Muchos equipos la interpretan como un interruptor global: si el usuario la activa, se desactivan todas las transiciones y el resultado es una interfaz que cambia de estado a saltos secos, donde nada indica qué se ha movido a dónde. Eso empeora la comprensión para todo el mundo, incluido quien activó la preferencia. Lo que la especificación de las consultas de medios describe es más preciso: la preferencia expresa el deseo de menos movimiento en pantalla, no de menos animación. Un cambio de opacidad, un cambio de color, un desvanecimiento cruzado no producen desplazamiento y no son lo que la preferencia pide eliminar. Lo que sí lo produce, y lo que hay que quitar o reducir drásticamente, es el desplazamiento de grandes superficies, el paralaje, el zoom, la rotación, el rebote y, muy señaladamente, el scroll animado de página completa. La traducción práctica es una regla de sustitución en lugar de una de eliminación: cuando la preferencia esté activa, cambia el movimiento por un desvanecimiento, no por nada. La interfaz sigue comunicando el cambio de estado, y el estímulo vestibular desaparece. Esa es la razón por la que en las lecciones de este tramo verás transition-duration: 1ms en lugar de transition: none: mantener la transición viva conserva los eventos y la secuencia, y solo elimina el tiempo durante el que hay movimiento.

⚔️ Comprueba el comportamiento
  1. Declara scroll-behavior: smooth en html y comprueba que la rueda del ratón no se ve afectada.
  2. Activa la preferencia de movimiento reducido en tu sistema y verifica cuál de las dos formas de declararlo te protege.
  3. Interrumpe un desplazamiento suave con la rueda a mitad de camino y observa que se cancela.
  4. Combínalo con scroll-snap-type y comprueba que el desplazamiento acaba en un punto de ajuste.
  5. Navega a un fragmento con una cabecera pegajosa y corrige el resultado con scroll-padding-block-start.