Pular para o conteúdo
~/.primo-academy.sh
☰ Aulas · Motion: animacao JS declarativa, View Transitions, Rive e a11y · 0/7
Recomendado: essencial

Acessibilidade em animacao: prefers-reduced-motion, performance, WCAG 2.3.3

7 min de leitura

fonte

Ate agora, toda a discussao foi "como fazer animacoes legais". Mas 5-15% dos usuarios tem algum grau de vestibular disorder ou sensibilidade a movimento. Pra esses usuarios, parallax agressivo, scale no hover, e slides nao sao "polimento" - sao gatilho de nausea, tontura, e enxaqueca. O navegador ja oferece um media query proprio (prefers-reduced-motion) pra esses usuarios se manifestarem, e respeitar isso e' requisito de WCAG 2.3.3 (nivel AAA).

Se voce entende prefers-reduced-motion + useReducedMotion + MotionConfig + os limites de WCAG 2.3.3 (3 flashes por segundo), voce entrega animacoes que funcionam pra todos - nao como "feature extra", mas como default desde o design.

O essencial 🟢

O media query prefers-reduced-motion. O navegador expõe a preferencia do usuario via media query CSS e JS:

/* CSS: detecta se o usuario prefere reduced motion */
@media (prefers-reduced-motion: reduce) {
  /* Reseta todas as animacoes */
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
// JS: detecta em runtime
const prefersReduced = window.matchMedia(
  "(prefers-reduced-motion: reduce)"
).matches;

O que significa "reduced motion". O usuario disse pro sistema operacional "minimize animacao nao-essencial". O OS transmite isso pro browser, e o browser expõe via media query. Em 2026:

  • macOS / iOS: System Settings > Accessibility

    Display > Reduce motion.

  • Windows: Settings > Accessibility > Visual effects > Animation effects.
  • Android: Settings > Accessibility > Remove animations.
  • Linux: varia (GNOME, KDE tem opcoes).

O usuario nao ta dizendo "sem animacao" - ta dizendo "menos, ou mais simples". A regra pratica: se a animacao nao e essencial pra entender a UI, simplifique.

Por que isso importa (alem de WCAG). Estudos da WebAIM mostram que 5-15% dos usuarios tem algum grau de sensibilidade visual/vestibular. Animacoes grandes (parallax agressivo, zoom, slide) podem causar:

  • Nausea, tontura, vertigem.
  • Enxaqueca.
  • Dificuldade de foco.

Pra esses usuarios, uma landing page com parallax "moderno" e' inacessivel, nao "enfeitada". Respeitar prefers-reduced-motion e' literalmente deixar o usuario usar o produto.

useReducedMotion() no Motion. O hook useReducedMotion retorna um boolean que reage a mudanca de preferencia em tempo real (nao so no load):

import { motion, useReducedMotion } from "motion/react";

function Hero() {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 50 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: shouldReduceMotion ? 0 : 0.5 }}
    >
      Bem-vindo
    </motion.div>
  );
}

Se o usuario tem reduce, a animacao some (y: 0 desde o inicio, duration 0). Senao, fade + slide up. O hook reage dinamicamente: se o usuario troca a preferencia durante o uso (raro, mas possivel), o componente re-renderiza.

MotionConfig reducedMotion="user" - default global. Em vez de verificar useReducedMotion em cada componente, voce define o comportamento uma vez na raiz:

import { MotionConfig } from "motion/react";

function App() {
  return (
    <MotionConfig reducedMotion="user">
      <Hero />      {/* nao precisa de useReducedMotion */}
      <Modal />     {/* Motion automaticamente simplifica */}
      <Toast />     {/* animacoes viram instantaneas */}
    </MotionConfig>
  );
}

reducedMotion="user" faz Motion:

  • Transform-based animations (x, y, scale, rotate): viram instantaneas (duration 0).
  • Non-transform animations (opacity, color): continuam normais (porque nao causam movimento).

E o ponto-chave: Motion entende a diferenca entre "animacao que move elementos" (ruim pra vestibular) e "animacao que muda cor" (neutra). So as primeiras sao removidas.

useReducedMotion vs MotionConfig. Os dois coexistem:

  • MotionConfig reducedMotion="user": cobre 80% dos casos. Voce nao pensa nisso componente por componente.
  • useReducedMotion(): pra casos custom (ex: "se reduced, troca parallax por static").

A recomendacao: sempre coloque MotionConfig reducedMotion="user" na raiz da app. Use useReducedMotion so quando precisar de logica custom.

WCAG 2.3.3 - limite de 3 flashes por segundo. O WCAG (Web Content Accessibility Guidelines) tem criterios especificos pra animacao. O mais importante:

2.3.3 Animation from Interactions (AAA): Para animacao disparada por interacao, ela deve ser dispensavel, a menos que seja essencial para a funcionalidade.

E criticos relacionados:

  • 2.3.1 Three Flashes: nenhum conteudo pode piscar mais de 3 vezes por segundo (causa convulsão fotosensivel).
  • 2.2.2 Pause, Stop, Hide: animacao com duracao > 5s deve ter controle de pause.

O que "essencial" significa em 2026. A excecao a 2.3.3 e' animacao essencial:

  • Indicador de loading (sem animacao, o usuario nao sabe se carregou).
  • Barra de progresso (sem animacao, perde feedback).
  • Cursor customizado com animacao de movimento (acessibilidade especifica).

Quase tudo o mais (parallax, slide-in, hover scale, theme transition) nao e' essencial e deve ser dispensavel. A interpretacao dominante: se o conteudo e' entendivel sem a animacao, ela nao e' essencial.

Pattern: motion-simplified vs motion-disabled vs motion-essential. O especialista de a11y Adrian Roselli classifica animacoes em 3 niveis:

  • Motion-essential: sem animacao, a UI nao funciona (loading spinner, progress). Sempre animar.
  • Motion-simplified: animacao ajuda mas pode ser simplificada (parallax vira fade, slide vira opacity).
  • Motion-disabled: animacao e' puro enfeite, sem perda semantica (pulse no icone, hover scale em botao).

Pra cada nivel, trate diferente:

function BotaoAnimado() {
  const shouldReduce = useReducedMotion();

  return (
    <motion.button
      whileHover={shouldReduce ? {} : { scale: 1.05 }}
      whileTap={shouldReduce ? {} : { scale: 0.95 }}
    >
      Salvar
    </motion.button>
  );
}

shouldReduce true = nada de scale. Senao, scale normal. Pattern classico de "motion-disabled" - hover scale e' puro enfeite, dispensavel.

useReducedMotion no Rive/Lottie. Pra animacoes importadas, pause/stop em vez de remover:

import { useEffect, useState } from "react";
import { useReducedMotion } from "motion/react";

function LottieAnim({ data }) {
  const shouldReduce = useReducedMotion();
  const [paused, setPaused] = useState(false);

  useEffect(() => {
    setPaused(shouldReduce);
  }, [shouldReduce]);

  return (
    <Lottie
      animationData={data}
      autoplay={!paused}
    />
  );
}

Se o usuario prefere reduce, a animacao nao roda (paused from start). A escolha e' sua: ou remove o componente, ou pausa a animacao. Recomendo pausar - alguns elementos ainda fazem sentido estaticos (loading congelado e' confuso, mas uma ilustracao decorativa congelada e' OK).

Por que a11y nao e' "feature extra". A discussao classica e' "vamos adicionar a11y depois do MVP". Isso e' um erro por 2 motivos:

  1. E' mais barato fazer desde o inicio. Adicionar prefers-reduced-motion awareness custa 2 linhas. Refatorar 50 animacoes depois custa 1 sprint.

  2. Inclusao e' requisito legal (em varios paises - EAA na Europa desde 2025, ADA nos EUA, Lei Brasileira de Inclusao). Nao e' "nice to have".

A regra: trate prefers-reduced-motion como default, nao como caso especial.

A maquina de estados. Veja o diagrama abaixo - como o sistema lida com prefers-reduced-motion em diferentes cenarios:

Fluxo de decisao: como o sistema trata prefers-reduced-motion em diferentes contextos (CSS, Motion, Rive/Lottie). O objetivo final e' WCAG 2.3.3 compliance para todos os usuarios.

Aprofundamento 🟡

reducedMotion="always" vs "never" vs "user". O MotionConfig aceita 3 valores:

<MotionConfig reducedMotion="user">  {/* default: respeita user */}
<MotionConfig reducedMotion="always">  {/* sempre reduced, ignora user */}
<MotionConfig reducedMotion="never">   {/* nunca reduced, sempre anima */}

"user" e' o que voce quer em producao. "always" e util pra debug: voce pode forcar o modo reduced sem mexer no OS. "never" e' util pra demos/showcases (mas nao use em producao - exclui usuarios sensíveis).

Debug de reduced motion. Pra testar sem mudar o OS:

  • Chrome DevTools: Rendering tab > Emulate CSS media feature > prefers-reduced-motion.
  • Firefox DevTools: Inspector > Layout > "Disable animations".
  • macOS Chrome: tambem responde ao "Reduce motion" do macOS.

useReducedMotion() reage em tempo real nessas emulacoes. Teste o componente com e sem reduced pra garantir que a UI faz sentido nos dois modos.

motion-safe: e motion-reduce: no Tailwind. Se voce usa Tailwind, ele ja oferece variants:

<div class="motion-safe:animate-spin motion-reduce:hidden">
  Loading...
</div>
  • motion-safe: so aplica se prefers-reduced-motion: no-preference.
  • motion-reduce: so aplica se prefers-reduced-motion: reduce.

Util pra esconder loading custom em reduced mode, ou pra simplificar animacoes CSS rapidamente.

**@media (prefers-reduced-motion: no-preference)

  • opt-in pra animacao.** A maioria dos devs faz o contrario: anima sempre, simplifica em reduce. Mas a abordagem "mais acessivel" e' opt-in:
.card {
  /* sem animacao por default */
}

@media (prefers-reduced-motion: no-preference) {
  .card {
    transition: transform 0.3s ease;
  }
  .card:hover {
    transform: scale(1.05);
  }
}

Se o usuario nao declarou preferencia, a animacao roda. Se declarou reduce, nao roda. E' mais seguro porque presume "no animation" como estado base. Em projetos grandes, misture: MotionConfig global pra Motion + @media (prefers-reduced-motion) pra CSS legado.

scroll-behavior: smooth e reduced. A propriedade CSS scroll-behavior: smooth tambem deve respeitar reduce:

html {
  scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;  /* sem smooth scroll */
  }
}

Smooth scroll (animado) pode causar desconforto em usuarios sensiveis. Reset em reduced mode.

Testando a11y de animacao. 3 formas praticas:

  1. Manual: ativar reduce motion no OS, navegar o app, sentir onde fica ruim.

  2. axe-core / Lighthouse: rodam axe que checa alguns criterios de animacao (3 flashes, alt text em animacoes decorativas). Nao cobre todos.

  3. Teste com usuarios reais: incluir usuarios com sensibilidade no user research. Mais trabalhoso, mais honesto.

O prefers-reduced-motion nao tem teste automatizado confiavel - voce precisa visualizar o app em modo reduced.

Pra quem quer ir mais alem 🔴

Por que Motion diferencia transform vs opacity em reduced mode. Internamente, o Motion sabe que:

  • transform: translateX(100px) -> 0 move elementos no espaco -> causa vertigem.
  • opacity: 0 -> 1 so muda transparencia -> neutro (nao causa vertigem).

Entao, em reducedMotion="user":

  • Transform animations: viram instantaneas (duration 0, mas interpolam mesmo assim pra evitar "salto" tecnico).
  • Opacity / color / filter: continuam normais.

Isso e' a diferenca entre "remover animacao" e "simplificar animacao". Motion simplifica de forma inteligente.

update callback em useReducedMotion - ouvir mudancas. Em vez de só ler o valor, voce pode reagir a mudancas:

import { useReducedMotion } from "motion/react";

function App() {
  const shouldReduce = useReducedMotion();

  useEffect(() => {
    if (shouldReduce) {
      console.log("Usuario ativou reduced motion");
      // analytics, ajustar config global, etc
    }
  }, [shouldReduce]);
}

Util pra analytics ("quantos % dos usuarios preferem reduced?") e pra ajustar config global que nao e' Motion (Lottie, Rive).

MotionConfig e useReducedMotion - quem vence. Se voce tem MotionConfig reducedMotion="always" e usa useReducedMotion, o useReducedMotion retorna true (porque "always" sobrescreve). A hierarquia: MotionConfig > useReducedMotion. Use MotionConfig pra override global (testes, demos), useReducedMotion pra detectar preferencia do usuario.

Flash test - o que WCAG 2.3.1 exige. Alem de reduced motion, WCAG 2.3.1 proibe mais de 3 flashes por segundo em qualquer area > 25% da tela. Isso e' muito restritivo:

  • Loading spinners pulsando: tipicamente OK (flash lento, 1-2 hz).
  • Videos com strobe: cuidado (Hollywood ainda faz isso em epilepsia warning).
  • Animacoes de "erro" pulsando em vermelho: cuidado (pode ser > 3 hz se rapido).

A regra: se sua animacao pisca, garanta que pisca < 3 vezes por segundo. Isso e' absoluto - nao depende de prefers-reduced-motion.

update callback pra debug em dev. Um pattern util em dev:

function App() {
  const shouldReduce = useReducedMotion();

  if (process.env.NODE_ENV === "development") {
    console.log("[motion]", { shouldReduce });
  }

  // ...
}

Em dev, log no console toda vez que shouldReduce muda. Ajuda a debugar casos "no dev funciona, em prod nao" (raro, mas acontece quando o OS do dev tem configuracao diferente do staging).

Leitura recomendada:

Dica: o erro mais comum em a11y de animacao e' usar useReducedMotion em 1 componente e esquecer o resto. A consistencia vem do MotionConfig reducedMotion="user" global na raiz. Use o hook so pra logica custom. Regra: MotionConfig na raiz = a11y basica garantida sem pensar em cada componente.

No proximo no, vamos ao projeto final: uma landing page com 3-5 animacoes intencionais (hero fade-in, theme toggle com View Transitions, card com layoutId, e parallax de scroll), todas respeitando prefers-reduced-motion desde o design.

// Quiz

Qual a diferenca entre `MotionConfig reducedMotion='user'` e `useReducedMotion()` no Motion?

Escolha uma alternativa

// recursos

// avaliação da trilha

—
ainda sem avaliações