prefers-reduced-motion a fondo
De dónde viene la consulta, cómo se activa en cada sistema, las dos formas de escribirla, qué hace realmente el apagón universal que todo el mundo copia y por qué no basta con la consulta.
La consulta existe desde 2017 y la mayoría de los sitios que la implementan lo hacen copiando el mismo bloque de cinco líneas con !important. Ese bloque es mejor que nada y es peor de lo que parece: apaga por igual lo que marea y lo que informa, y desactiva animaciones cuyo estado final depende de que se ejecuten. Vale la pena entender qué señal transporta exactamente la consulta, qué no transporta, y por qué la respuesta correcta casi nunca es un interruptor global.
- Explicar qué preferencia del sistema operativo lee la consulta en cada plataforma.
- Elegir entre la forma de exclusión y la de inclusión con un criterio, no por costumbre.
- Analizar el bloque universal de reinicio: qué resuelve, por qué usa 0,01 ms y qué rompe.
- Detectar la preferencia desde JavaScript, emularla al probar y complementarla con un control en la página.
Qué señal transporta
prefers-reduced-motion es una consulta de medios de nivel 5 con dos valores: reduce y no-preference. No mide nada del usuario: refleja un ajuste del sistema operativo.
Su origen es concreto. En 2013 iOS 7 introdujo un paralaje en la pantalla de inicio y un efecto de zoom al abrir aplicaciones, y una cantidad notable de personas informó de mareos y náuseas. Apple añadió el ajuste Reducir movimiento, y unos años después ese ajuste llegó a macOS y, con Safari 10.1 en 2017, a la web. Firefox lo implementó en 2018 y Chrome en 2019. Hoy está en los cuatro motores.
Dónde lo activa la gente:
| Sistema | Ruta |
|---|---|
| macOS | Ajustes del Sistema, Accesibilidad, Pantalla, Reducir movimiento |
| iOS y iPadOS | Ajustes, Accesibilidad, Movimiento, Reducir movimiento |
| Windows | Configuración, Accesibilidad, Efectos visuales, Efectos de animación |
| Android | Ajustes, Accesibilidad, Quitar animaciones |
| GNOME | Ajustes, Accesibilidad, Reducir animación |
Y un matiz que cambia cómo hay que interpretar la señal: no todo el que activa esa preferencia tiene un trastorno vestibular, y no todo el que lo tiene sabe que la preferencia existe. La gente la activa para ahorrar batería, para reducir distracciones, porque tiene un dispositivo lento, porque le gusta más así, o porque un compañero se la activó y nunca la ha vuelto a mirar. La consecuencia práctica es doble y va en las dos direcciones: no envíes una experiencia empobrecida a quien la activa —no es una persona a la que haya que compensar, es una preferencia—, y no des por hecho que quien no la tiene activada está a salvo.
Las dos formas de escribirla
Exclusión. Escribes el movimiento por defecto y lo anulas cuando hay preferencia.
.panel {
transition: translate 280ms cubic-bezier(0.2, 0, 0, 1), opacity 180ms linear;
}
@media (prefers-reduced-motion: reduce) {
.panel { transition-duration: 1ms; }
}
Inclusión. El movimiento solo existe dentro de la consulta.
.panel { opacity: 0; }
.panel.visible { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
.panel { transition: translate 280ms cubic-bezier(0.2, 0, 0, 1), opacity 180ms linear; }
}
La diferencia real no es estilística. En la forma de exclusión, olvidarse produce movimiento; en la de inclusión, olvidarse produce quietud. Con cuarenta componentes y varias personas tocándolos, alguien se va a olvidar, y la pregunta es qué prefieres que pase entonces.
La forma de inclusión es la defensiva y es la que deberías usar para el movimiento grande: paralaje, zooms, transiciones de página, cualquier cosa que ocupe pantalla. La de exclusión es más cómoda y perfectamente aceptable para el movimiento pequeño y funcional, donde el fallo por omisión es un desvanecimiento de ciento sesenta milisegundos que no le hace daño a nadie.
Hay un tercer patrón, mejor que los dos, que es el que se usa en un sistema de movimiento maduro: no escribir la consulta en cada componente, sino una sola vez en los tokens.
:root {
--mov-rapido: 140ms;
--mov-medio: 240ms;
--mov-lento: 420ms;
--mov-desplazamiento: 1; /* multiplicador de distancia */
}
@media (prefers-reduced-motion: reduce) {
:root {
--mov-rapido: 1ms;
--mov-medio: 1ms;
--mov-lento: 1ms;
--mov-desplazamiento: 0;
}
}
.tarjeta {
transition: translate var(--mov-medio) ease-out, opacity var(--mov-rapido) linear;
translate: 0 calc(12px * var(--mov-desplazamiento));
}
Un componente que usa los tokens cumple la preferencia sin saber que existe. Y ese multiplicador de desplazamiento es la primera pieza del patrón de dos versiones, que es el tema de la lección siguiente.
El apagón universal
Este bloque circula desde hace años y está en miles de sitios:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Qué hace bien. Es una red de seguridad de una sola regla que cubre el código que no controlas: la librería de terceros, el widget incrustado, el componente que alguien escribió sin pensar en esto. Como suelo, es defendible.
Por qué 0,01 ms y no 0. Este detalle es la parte inteligente del bloque y casi nadie sabe explicarlo. Una animación con duración cero no dispara los eventos transitionend ni animationend en todos los casos. Si tu código espera uno de esos eventos para quitar un nodo, para devolver el foco o para continuar una secuencia, con duración cero se queda esperando para siempre. Con 0,01 ms la animación es instantánea a efectos visuales y el evento se dispara igual. Es una solución a un problema real.
Qué rompe. Tres cosas, y hay que conocerlas antes de pegarlo.
Anula el movimiento que informa. Un indicador de carga que deja de girar deja de informar de que hay algo en marcha. Un elemento que se desplaza para mostrar de dónde viene deja de mostrarlo. El bloque no distingue.
Rompe las animaciones de varios pasos con estados intermedios. Una animación con steps() que muestra una secuencia, o una que depende de que ciertos keyframes se ejecuten en orden para dejar el elemento en un estado concreto, colapsa al último fotograma. A veces eso está bien y a veces deja algo a medias.
El !important es irreversible. No puedes reactivar el movimiento en un componente concreto donde sí tenga sentido, porque nada tiene más peso que un !important en un selector universal dentro de la misma capa. Si además quieres ofrecer un control en la página para volver a activar el movimiento, no puedes: la regla gana siempre.
La versión sensata mantiene la red de seguridad y deja una vía de escape:
@layer reset {
@media (prefers-reduced-motion: reduce) {
*:not([data-mov="permitido"]),
*:not([data-mov="permitido"])::before,
*:not([data-mov="permitido"])::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
Con eso, un componente que ha diseñado su versión reducida a conciencia se marca con data-mov="permitido" y gestiona su propio movimiento, y todo lo demás sigue cubierto por el suelo.
scroll-behavior: smooth desplaza la pantalla entera de forma automática y no iniciada directamente por el gesto. Es exactamente el perfil de movimiento grande y no predicho que más conflicto produce. Que esté en el bloque universal no es un adorno: es probablemente lo más importante que hay ahí.
Detectar, emular y no depender solo de esto
Desde JavaScript, matchMedia da el valor y notifica los cambios. Reaccionar al cambio importa: la gente activa la preferencia mientras tu página está abierta, precisamente porque tu página le está haciendo daño.
const consulta = window.matchMedia("(prefers-reduced-motion: reduce)");
function aplicar() {
document.documentElement.dataset.movimiento = consulta.matches ? "reducido" : "completo";
}
consulta.addEventListener("change", aplicar);
aplicar();
Para probarlo sin tocar los ajustes del sistema, las DevTools de Chromium tienen la emulación en el panel Rendering, en Emulate CSS media feature prefers-reduced-motion. En Firefox se fuerza desde about:config con ui.prefersReducedMotion a 1. Y en una prueba automatizada de Playwright:
await page.emulateMedia({ reducedMotion: "reduce" });
Añade esa línea a un caso de prueba que recorra las pantallas principales y haz una comparación visual. Es la única forma de que la versión reducida no se pudra: si nadie la ejecuta, se rompe en la tercera refactorización y nadie se entera.
Y el punto final, que es el más importante de la lección. La consulta no es suficiente. Cubre a quien conoce el ajuste y lo tiene activado, que es una minoría de las personas afectadas. Si tu página tiene movimiento grande —paralaje, zoom de fondo, vídeo automático, transiciones de página amplias— pon un control visible en la propia página, cerca de donde está el movimiento o en el pie, que lo desactive y recuerde la decisión.
const raiz = document.documentElement;
const consulta = window.matchMedia("(prefers-reduced-motion: reduce)");
const guardado = localStorage.getItem("movimiento"); // "reducido" | "completo" | null
function estado() {
if (guardado) return guardado;
return consulta.matches ? "reducido" : "completo";
}
function aplicar() {
raiz.dataset.movimiento = estado();
boton.setAttribute("aria-pressed", String(raiz.dataset.movimiento === "reducido"));
}
const boton = document.querySelector("#reducir-movimiento");
boton.addEventListener("click", () => {
const nuevo = raiz.dataset.movimiento === "reducido" ? "completo" : "reducido";
localStorage.setItem("movimiento", nuevo);
aplicar();
});
consulta.addEventListener("change", aplicar);
aplicar();
Y en CSS, el atributo tiene que poder ganar a la consulta en las dos direcciones:
:root[data-movimiento="reducido"] {
--mov-rapido: 1ms;
--mov-medio: 1ms;
--mov-lento: 1ms;
--mov-desplazamiento: 0;
}
:root[data-movimiento="completo"] {
--mov-rapido: 140ms;
--mov-medio: 240ms;
--mov-lento: 420ms;
--mov-desplazamiento: 1;
}
Como los dos selectores tienen la misma especificidad y el atributo se escribe siempre, el orden en la hoja determina el resultado y no hay conflicto: la decisión explícita del usuario manda sobre todo lo demás, que es exactamente lo que quieres.
Hay una tentación muy fuerte de leer prefers-reduced-motion: reduce como “esta persona tiene un trastorno vestibular” y actuar en consecuencia, y esa lectura produce errores en las dos direcciones. Por un lado lleva a sobrecompensar: se envía una versión pelada, sin ninguna transición, a veces incluso con menos funcionalidad, como si activar un ajuste de accesibilidad significara renunciar a la calidad del producto. Esa versión la reciben sobre todo personas que activaron la preferencia por batería o por gusto, que ahora usan una interfaz peor sin haber pedido nada parecido, y que además pierden las transiciones cortas de opacidad que las ayudaban a seguir los cambios y que no le hacen daño a nadie. Por otro lado lleva a infracompensar, que es peor: se asume que quien no tiene la preferencia activada tolera cualquier cosa, y se mete un paralaje a pantalla completa detrás de la consulta con la conciencia tranquila. Pero la mayoría de las personas con sensibilidad al movimiento no sabe que ese ajuste existe, ni que existe en la web, ni que su navegador lo lee; lo que sabe es que ciertas páginas le sientan mal y las cierra. La forma correcta de leer la señal es más humilde: es una preferencia declarada sobre el movimiento, con la fiabilidad de una preferencia declarada. Cumplirla es obligatorio y no basta. Lo que de verdad protege es que el movimiento peligroso —el que simula desplazamiento propio— no dependa de ninguna señal para poder desactivarse: que esté detrás de un control visible, o mejor, que sencillamente no esté. Un producto que solo tiene movimiento pequeño, breve y funcional no necesita que nadie declare nada, y esa es la versión más accesible de todas: la que no tiene que preguntar.