wandres.dev
ACCESIBILIDAD · prefers-reduced-motion en serio

Movimiento que informa frente a movimiento que decora

Los cuatro trabajos que hace una animación funcional, el presupuesto del movimiento decorativo, qué exigen los criterios de WCAG que aplican, y los fallos de foco y de lectura que provocan las animaciones.

⏱ 20 min

La distinción entre movimiento que informa y movimiento que decora se cita mucho y se aplica poco, porque casi nadie la convierte en una prueba concreta. Sí lo es: el movimiento funcional hace uno de cuatro trabajos identificables, y si no hace ninguno, decora. Esa clasificación decide qué se puede quitar sin consecuencias, qué hay que sustituir con cuidado y qué obligaciones normativas te aplican. Y hay un segundo frente que se olvida siempre: las animaciones rompen el foco y confunden a los lectores de pantalla de formas muy concretas.

🎯 Al terminar esta lección sabrás
  • Clasificar una animación según los cuatro trabajos del movimiento funcional.
  • Fijar y defender un presupuesto para el movimiento decorativo.
  • Aplicar los criterios de WCAG que afectan al movimiento y saber qué significa “esencial”.
  • Corregir los fallos de foco, de anuncio y de revelado que introducen las animaciones.

Los cuatro trabajos

Una animación informa si hace al menos una de estas cuatro cosas. Si no hace ninguna, decora, y eso no está prohibido: solo cambia las reglas que le aplican.

Continuidad espacial: de dónde viene y adónde va. Un panel que entra desde el borde derecho enseña que vive fuera de la pantalla a la derecha y que volverá allí. Una tarjeta que se expande a pantalla completa enseña que el detalle y el resumen son el mismo objeto. Sin ese movimiento, el usuario tiene que reconstruir la relación entre dos estados, y a veces no la reconstruye: cree que es contenido nuevo.

Causalidad: esto ha pasado porque has hecho aquello. Un botón que se hunde al pulsar, una fila que se desliza justo después de arrastrarla, un contador que sube tras confirmar. Aquí lo que importa es la latencia más que la trayectoria: la respuesta tiene que llegar dentro de los primeros cien milisegundos para que el cerebro la atribuya al gesto. Pasado ese umbral la conexión se debilita y a partir de un segundo se pierde por completo.

Estado del sistema: algo está pasando. Es el trabajo del indicador de carga, del esqueleto, de la barra de progreso. Comunica que el sistema no está muerto. Es el único de los cuatro que se puede hacer sin ningún desplazamiento —un pulso de opacidad lo hace igual de bien— y por eso es el más fácil de reducir.

Jerarquía: mira aquí primero. El escalonado que hace que el ojo recorra una lista en orden, el retardo que hace que el título llegue antes que el cuerpo. Este trabajo es real pero es el que más se sobreestima: en una interfaz que se usa treinta veces al día, dirigir la atención en el orden que tú quieres deja de funcionar el tercer día y lo único que queda es la espera.

Todo lo demás decora: el rebote, el brillo que recorre el botón, las partículas al completar una tarea, el fondo que se mueve, el logotipo que se dibuja. Repito que decorar no es un pecado. Es la personalidad del producto y a veces es la razón por la que alguien lo recuerda. Pero tiene un presupuesto.

El presupuesto de lo decorativo

Tres reglas que hacen que el movimiento decorativo no se convierta en un problema, y que valen para cualquier producto.

Nunca en la ruta crítica. Nada decorativo debe retrasar el momento en que la persona puede hacer lo que vino a hacer. Un logotipo que se dibuja durante mil doscientos milisegundos antes de que aparezca el formulario es un impuesto de mil doscientos milisegundos cobrado a cada visita.

Nunca en bucle indefinido en el campo periférico. El movimiento continuo que no puedes evitar mirar es la forma más rápida de agotar a alguien, y es también la que más consume: un bucle de fondo gasta batería y hilo principal durante toda la sesión.

Nunca repetido en cada uso. Una animación de celebración la primera vez que alguien completa algo es un detalle bonito. La misma animación la vez número cuarenta es una espera. Si algo se hace muchas veces al día, su animación debe ser corta, discreta y sustituible.

Esa última regla tiene un corolario que ahorra muchas discusiones: la duración correcta de una animación es inversamente proporcional a la frecuencia con que se ve. Lo que se ve una vez puede durar; lo que se ve cien veces tiene que ser casi invisible.

Lo que dice la norma

Cuatro criterios de WCAG afectan directamente al movimiento. Conviene conocerlos por su número porque es como aparecen en las auditorías.

2.2.2 Poner en pausa, detener, ocultar (nivel A). Si hay contenido que se mueve, parpadea o se desplaza, que empieza automáticamente, que dura más de cinco segundos y que se presenta junto a otro contenido, tiene que haber un mecanismo para pausarlo, detenerlo u ocultarlo. Las tres condiciones son acumulativas. Un carrusel que avanza solo lo incumple; un vídeo de fondo en bucle lo incumple; una animación de entrada de novecientos milisegundos no, porque no llega a los cinco segundos.

2.3.1 Tres destellos o por debajo del umbral (nivel A). Nada puede destellar más de tres veces por segundo, salvo que los destellos estén por debajo de unos umbrales definidos de área y de contraste. Este criterio no protege contra el mareo: protege contra las crisis fotosensibles, que es un riesgo distinto y más grave.

2.3.3 Animación desde interacciones (nivel AAA). El movimiento disparado por una interacción se puede desactivar, salvo que sea esencial. Es nivel AAA, así que casi nadie lo exige por contrato, y es exactamente lo que implementas al respetar prefers-reduced-motion.

1.4.2 Control del audio (nivel A). Si algo suena automáticamente más de tres segundos, tiene que haber forma de pararlo. Aplica al vídeo con sonido, que suele acompañar a las animaciones grandes.

La palabra esencial tiene una definición precisa en WCAG y no significa “importante”: significa que quitarlo cambiaría de forma fundamental la información o la funcionalidad. La animación de una demostración de un movimiento físico es esencial; la animación de entrada de tu panel lateral no lo es, por mucho que le guste al equipo. En la práctica, casi ninguna animación de interfaz es esencial en el sentido normativo, y conviene saberlo antes de defender lo contrario en una auditoría.

⚠️
El límite de cinco segundos se incumple casi siempre por accidente

Un bucle de fondo, un esqueleto que sigue brillando porque la petición tarda, un carrusel con avance automático, un vídeo decorativo: los cuatro superan los cinco segundos sin que nadie lo decidiera. La comprobación es trivial —abre la página, no toques nada, cuenta hasta seis— y detecta el incumplimiento más común de todos.

Foco, anuncios y contenido a medio revelar

Aquí es donde las animaciones causan daños que no tienen nada que ver con el mareo y que casi nadie prueba.

El contenido oculto para revelarlo sigue existiendo. El patrón de revelado al hacer scroll suele empezar con opacity: 0. Un elemento con opacidad cero es visible para el lector de pantalla, es enfocable con el tabulador y aparece en la búsqueda de la página. Un usuario de teclado que tabula rápido acaba con el foco en un enlace que no puede ver, y el navegador desplaza la pantalla a un sitio aparentemente vacío. Es un fallo grave y se arregla de una forma concreta: el estado oculto lo añade JavaScript, no el CSS de partida, y se quita en cuanto la animación termina.

const revelables = document.querySelectorAll("[data-revelar]");
const sinMovimiento = matchMedia("(prefers-reduced-motion: reduce)").matches;

if (!sinMovimiento && "IntersectionObserver" in window) {
  // Solo ahora se oculta: sin JS, o con movimiento reducido, el contenido
  // esta visible desde el primer pintado.
  revelables.forEach((el) => el.setAttribute("data-oculto", ""));

  const obs = new IntersectionObserver((entradas) => {
    for (const e of entradas) {
      if (!e.isIntersecting) continue;
      obs.unobserve(e.target);
      const anim = e.target.animate(
        [{ opacity: 0, transform: "translateY(16px)" }, { opacity: 1, transform: "none" }],
        { duration: 380, easing: "cubic-bezier(0, 0, 0.2, 1)", fill: "both" }
      );
      // Y se quita el estado en cuanto termina, para no dejar rastro.
      anim.finished.then(() => {
        anim.cancel();
        e.target.removeAttribute("data-oculto");
      });
    }
  }, { rootMargin: "0px 0px -10% 0px" });

  revelables.forEach((el) => obs.observe(el));
}
[data-oculto] { opacity: 0; }

El foco no debe estar dentro de algo que se mueve. Si el usuario pulsa el tabulador mientras un panel entra, el navegador desplazará la pantalla hasta el elemento enfocado, y ese elemento está cambiando de posición: el resultado es un salto errático. Un panel que aún no está en su sitio debe ser inert hasta que llegue.

panel.inert = true;
const anim = panel.animate(entrada, { duration: 260, easing: "cubic-bezier(0, 0, 0.2, 1)" });
anim.finished.then(() => {
  panel.inert = false;
  panel.querySelector("[autofocus], button, [href], input")?.focus();
});

El foco que desaparece. Al animar la salida de un elemento que contiene el foco, ese foco cae al body y el usuario de teclado pierde su sitio: el siguiente tabulador empieza desde el principio del documento. Hay que devolverlo explícitamente, y hay que hacerlo antes de empezar la salida, no después.

function cerrar(panel, disparador) {
  disparador.focus();              // primero el foco, con el panel aun visible
  panel.inert = true;
  panel.animate(salida, { duration: 180, fill: "forwards" })
       .finished.then(() => panel.remove());
}

El anuncio no espera a la animación. Si un mensaje aparece con un fundido de trescientos milisegundos y lo insertas en una región aria-live al empezar, el lector de pantalla lo anuncia inmediatamente, que es lo correcto: la información no debería llegar más tarde por un motivo estético. El error es el contrario —insertar el texto en el DOM cuando la animación termina— y produce un retraso de la información que no tiene ninguna justificación.

Casi todos los fallos de accesibilidad de una animación vienen del estado inicial, no del movimiento

Si auditas de verdad un puñado de sitios buscando problemas de accesibilidad relacionados con animación, encuentras una distribución que sorprende: la inmensa mayoría de los defectos no están en el movimiento sino en el estado que se usa para preparar el movimiento. Elementos con opacity: 0 esperando a ser revelados que el lector de pantalla anuncia y el tabulador visita. Paneles fuera de pantalla con translate que siguen en el orden de tabulación, de modo que el foco se va a un sitio invisible y el navegador desplaza la vista a la nada. Contenido con visibility: hidden que un revelado por scroll nunca llegó a mostrar porque el observador no se disparó en ese navegador. Menús desplegables que se quedan a mitad de la transición cuando el usuario navega rápido, en un estado que ninguna rama del código contemplaba. Todos estos son bugs de máquina de estados, no de animación, y comparten una causa: se ha usado una propiedad visual para representar un estado lógico, con lo que el árbol de accesibilidad y el árbol visual dejan de coincidir y a partir de ahí cualquier cosa puede pasar. La disciplina que los elimina de golpe es incómoda pero muy simple de enunciar: el estado lógico se representa con atributos que el navegador entiendehidden, inert, aria-expanded, open, popover— y la animación se cuelga de ese estado, nunca al revés. Un panel cerrado está inert o directamente fuera del DOM, no simplemente desplazado; un contenido no revelado no existe o está hidden, no tiene opacidad cero. Cuando se hace así, todos los fallos de esta lección desaparecen sin que hayas pensado en ninguno de ellos por separado, porque el navegador ya sabe qué significa cada uno de esos atributos y ya hace lo correcto con el foco, con el orden de lectura y con la búsqueda en la página. Y como beneficio secundario, tu código de animación se vuelve mucho más fácil de leer, porque deja de ser la definición del estado y pasa a ser lo que debía haber sido siempre: la transición entre dos estados que están definidos en otra parte.