CSS oferece duas ferramentas para movimento: transições e animações @keyframes. A maioria dos desenvolvedores sabe que ambas existem, mas recorre àquela que aprendeu primeiro — geralmente transições — e depois fica se perguntando por que o spinner de carregamento não faz loop ou por que a notificação volta de repente à posição original. A decisão não é complicada quando você entende para que cada uma serve. Transições são uma reação: algo muda de estado, o navegador suaviza a mudança. Animações @keyframes são scripts independentes: rodam por conta própria, fazem loop se você quiser e não precisam de um gatilho de estado. Este guia cobre os dois, as propriedades que mais importam, e os detalhes de desempenho que separam animações suaves de 60fps das que travam. Se você escreve muito CSS, vale marcar o CSS Formatter nos favoritos — ele limpa qualquer shorthand bagunçado rapidamente.

Transições CSS — O Caso Simples

Uma transição diz ao navegador: "quando esta propriedade mudar, anime a mudança ao longo do tempo em vez de pular." Só isso. O uso mais comum é em efeitos de hover — mudanças de cor, fades de opacidade, transformações sutis de escala. O guia de transições do MDN cobre o spec completo, mas as quatro propriedades que importam são transition-property, transition-duration, transition-timing-function e transition-delay.

css
/* O shorthand: property duration easing delay */
.btn-primary {
  background-color: #4f46e5;
  color: #fff;
  padding: 0.625rem 1.25rem;
  border-radius: 6px;
  border: none;
  cursor: pointer;
  transition: background-color 200ms ease, transform 150ms ease;
}

.btn-primary:hover {
  background-color: #4338ca;
  transform: translateY(-1px);
}

.btn-primary:active {
  transform: translateY(0);
}

/* Fade de opacidade — um card entrando na tela */
.notification-card {
  opacity: 0;
  transition: opacity 300ms ease-in-out;
}

.notification-card.is-visible {
  opacity: 1;
}

Você pode listar várias transições separadas por vírgulas, cada uma com sua própria duração e easing — é exatamente o que o exemplo do botão acima faz. Cada propriedade é animada independentemente.

Não use transition: all. É tentador como um pega-tudo, mas significa que toda mudança de propriedade naquele elemento é animada — incluindo as que você não pretendia. Também faz o navegador trabalhar mais em cada frame. Liste as propriedades específicas que você quer transicionar. O docs do MDN sobre transition-property explica quais propriedades são animáveis.

transition-timing-function — Easing Explicado

A função de timing controla a curva de aceleração da animação — se começa rápido e desacelera, move em velocidade constante ou salta por uma curva personalizada. As palavras-chave embutidas cobrem a maioria das situações.

  • ease — começa devagar, acelera e depois desacelera perto do fim. O padrão. Bom para a maioria dos movimentos de UI.
  • linear — velocidade constante do início ao fim. Bom para spinners e barras de progresso, ruim para a maioria das outras coisas — parece mecânico.
  • ease-in — começa devagar, termina rápido. Parece algo ganhando velocidade. Bom para elementos saindo da tela.
  • ease-out — começa rápido, termina devagar. Parece natural para elementos entrando na tela (como uma notificação deslizando para dentro).
  • ease-in-out — devagar nas duas extremidades. Parece polido para elementos que começam e param na tela.
  • cubic-bezier(x1, y1, x2, y2) — defina sua própria curva. Ferramentas como cubic-bezier.com permitem construir e visualizar curvas personalizadas.
css
/* Easing personalizado com efeito de mola usando cubic-bezier */
.drawer {
  transform: translateX(-100%);
  transition: transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.drawer.is-open {
  transform: translateX(0);
}

/* Os valores de cubic-bezier acima ultrapassam ligeiramente (y > 1)
   o que dá um satisfatório efeito de mola ao abrir */

@keyframes — Quando Transições Não São Suficientes

Transições têm um limite claro: precisam de dois estados e um gatilho. Se você quer que algo faça loop indefinidamente, rode no carregamento da página sem nenhuma interação, ou anime por mais de dois passos, você precisa de @keyframes. A referência @keyframes do MDN cobre a sintaxe completa. Você define os keyframes separadamente e depois os anexa a um elemento com a propriedade animation.

css
/* Definir a animação */
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* Anexá-la a um elemento */
.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #e5e7eb;
  border-top-color: #4f46e5;
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}

/* Multi-step — mais de dois pontos de parada */
@keyframes pulse-ring {
  0%   { transform: scale(1);    opacity: 1; }
  50%  { transform: scale(1.15); opacity: 0.7; }
  100% { transform: scale(1);    opacity: 1; }
}

.badge-live {
  animation: pulse-ring 1.5s ease-in-out infinite;
}

O Shorthand animation — Todas as Oito Propriedades

O shorthand animation empacota oito propriedades em uma única declaração. Você não precisa de todas toda vez, mas saber o que cada uma controla te salva de vasculhar a documentação quando algo não se comporta como esperado.

css
/*
  animation: name | duration | timing-function | delay | iteration-count | direction | fill-mode | play-state
*/
.toast {
  animation: slide-in-right 350ms ease-out 0s 1 normal forwards running;
}

/* Mais comumente escrito com apenas os valores necessários: */
.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

/* Dividido em propriedades individuais para clareza: */
.confetti-piece {
  animation-name: float-down;
  animation-duration: 2s;
  animation-timing-function: ease-in;
  animation-delay: calc(var(--i) * 150ms); /* escalonado via propriedade personalizada CSS */
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: forwards;
  animation-play-state: running;
}

/* Pausando uma animação via JavaScript: alternar uma classe */
.spinner.is-paused {
  animation-play-state: paused;
}

animation-fill-mode — Corrigindo o Problema do Snap-Back

Esta é a propriedade que tropeça todo mundo em algum momento. Por padrão, quando uma animação termina, o elemento volta repentinamente para seus estilos pré-animação — como se a animação nunca tivesse acontecido. Se você anima uma notificação toast deslizando da direita, ela vai se mover de volta para fora da tela assim que a animação terminar. animation-fill-mode: forwards corrige isso mantendo o elemento no estado do seu keyframe final quando a animação termina.

css
@keyframes slide-in-right {
  from {
    transform: translateX(110%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

/* Sem forwards: o toast desliza para dentro, depois VOLTA ABRUPTAMENTE para fora da tela */
.toast-bad {
  animation: slide-in-right 350ms ease-out;
}

/* Com forwards: o toast desliza para dentro e FICA na posição */
.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

/*
  valores de fill-mode:
  - none      (padrão) — sem estilos aplicados antes ou depois
  - forwards  — manter o keyframe final após o término da animação
  - backwards — aplicar o primeiro keyframe durante o período de delay
  - both      — forwards + backwards combinados
*/

Exemplos Reais — Skeleton Loader, Spinner, Toast, Badge Pulsante

Aqui estão quatro padrões que você usará constantemente. Cada um demonstra uma combinação específica de @keyframes e propriedades de animação.

css
/* --- 1. Shimmer de carregamento skeleton --- */
@keyframes shimmer {
  0%   { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite linear;
  border-radius: 4px;
}

.skeleton-title  { height: 20px; width: 60%; margin-bottom: 12px; }
.skeleton-text   { height: 14px; width: 100%; margin-bottom: 8px; }
.skeleton-text:last-child { width: 80%; }
css
/* --- 2. Loader girando --- */
@keyframes spin {
  to { transform: rotate(360deg); }
}

.loader-spinner {
  display: inline-block;
  width: 32px;
  height: 32px;
  border: 3px solid rgba(79, 70, 229, 0.2);
  border-top-color: #4f46e5;
  border-radius: 50%;
  animation: spin 600ms linear infinite;
}
css
/* --- 3. Toast de notificação deslizante --- */
@keyframes toast-enter {
  from {
    transform: translateX(calc(100% + 1.5rem));
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

@keyframes toast-exit {
  from {
    transform: translateX(0);
    opacity: 1;
  }
  to {
    transform: translateX(calc(100% + 1.5rem));
    opacity: 0;
  }
}

.toast {
  position: fixed;
  bottom: 1.5rem;
  right: 1.5rem;
  padding: 0.875rem 1.25rem;
  background: #1f2937;
  color: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
  animation: toast-enter 350ms ease-out forwards;
}

.toast.is-dismissing {
  animation: toast-exit 300ms ease-in forwards;
}
css
/* --- 4. Badge de notificação pulsante --- */
@keyframes badge-pulse {
  0%, 100% {
    box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
  }
  50% {
    box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
  }
}

.notification-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  background: #ef4444;
  color: #fff;
  font-size: 11px;
  font-weight: 700;
  border-radius: 999px;
  animation: badge-pulse 2s ease-in-out infinite;
}

Desempenho — Propriedades Compostas pelo GPU vs Que Disparam Layout

Nem todas as propriedades CSS são iguais quando se trata de desempenho de animação. O pipeline de renderização do navegador tem três estágios que importam aqui: layout (calculando tamanhos e posições dos elementos), paint (preenchendo pixels) e composite (combinando camadas no GPU). Animar uma propriedade que dispara layout significa que o navegador recalcula toda a geometria do documento em cada frame — isso é caro e é o que causa travamentos. O guia de desempenho de animações do web.dev cobre isso em profundidade.

  • Seguro para animar (composição GPU): transform (translate, scale, rotate) e opacity. Estes rodam inteiramente na thread do compositor GPU — nunca disparam layout ou paint.
  • Causa paint (evitar se possível): color, background-color, border-color, box-shadow. Estes pulam o layout mas disparam um repaint em cada frame. Transições curtas (abaixo de 300ms) geralmente estão bem.
  • Causa layout (nunca animar): width, height, top, left, margin, padding. Cada frame dispara um recálculo completo de layout — travamento garantido em páginas complexas.
A regra prática: anime apenas com transform e opacity. Se quiser mover algo, use translate em vez de left/top. Se quiser redimensionar algo, use scale em vez de width/height. CSS Triggers é uma referência que lista exatamente quais estágios de renderização cada propriedade aciona.
css
/* Ruim — animar width dispara layout em cada frame */
.progress-bar-bad {
  transition: width 300ms ease;
}

/* Bom — usar transform scaleX em vez disso */
.progress-bar {
  transform-origin: left center;
  transition: transform 300ms ease;
}

/* No JS, defina: progressBar.style.transform = 'scaleX(0.75)' para 75% */

/* Ruim — animar top/left (posiciona o elemento com layout) */
.tooltip-bad {
  position: absolute;
  top: 0;
  transition: top 200ms ease;
}

/* Bom — usar translateY em vez disso */
.tooltip {
  position: absolute;
  top: 0;
  transform: translateY(0);
  transition: transform 200ms ease;
}

will-change — Use com Moderação

will-change é uma dica ao navegador de que uma propriedade específica está prestes a ser animada, então ele deve promover o elemento para sua própria camada de compositor com antecedência. Isso pode eliminar o breve flash de travamento que às vezes você vê no início de uma animação em hardware de baixo desempenho. Mas tem um custo real: cada camada promovida consome memória GPU. Se você colocar will-change: transform em cada elemento animado do seu app, você vai degradar o desempenho na maioria dos dispositivos — o oposto do que queria.

css
/* Jeito certo — adicione logo antes da animação começar, remova depois */
.modal-overlay {
  /* Não defina will-change aqui por padrão */
}

/* Adicione via JavaScript apenas quando o usuário acionar abertura do modal */
/* overlay.style.willChange = 'opacity'; */
/* overlay.addEventListener('transitionend', () => { */
/*   overlay.style.willChange = 'auto'; */
/* }); */

/* Aceitável em CSS para elementos que animam frequentemente
   (ex.: um botão de ação flutuante persistente no scroll) */
.fab {
  will-change: transform; /* OK — este elemento genuinamente anima no scroll */
  transition: transform 200ms ease;
}

/* Não faça isso — desperdiça memória GPU sem nenhum benefício */
.card {
  will-change: transform; /* RUIM — o card só anima no hover, não constantemente */
}

prefers-reduced-motion — Acessibilidade que Você Não Pode Pular

Uma parcela significativa dos usuários tem distúrbios vestibulares ou outras condições em que o movimento provoca desconforto ou náusea. A media query prefers-reduced-motion permite respeitar a configuração de "reduzir movimento" no nível do sistema. A diretriz WCAG 2.1 2.3.3 cobre esse requisito, e a referência prefers-reduced-motion do MDN mostra o suporte de navegadores (agora universal). O padrão é simples: envolva suas animações na media query padrão e forneça um fallback sem movimento dentro da de reduced-motion.

css
/* Defina animações normalmente para usuários que estão bem com movimento */
@keyframes slide-in-right {
  from { transform: translateX(110%); opacity: 0; }
  to   { transform: translateX(0);    opacity: 1; }
}

.toast {
  animation: slide-in-right 350ms ease-out forwards;
}

.spinner {
  animation: spin 600ms linear infinite;
}

/* Override para usuários que preferem movimento reduzido */
@media (prefers-reduced-motion: reduce) {
  .toast {
    /* Sem deslizamento — apenas aparecer */
    animation: none;
    opacity: 1;
    transform: none;
  }

  .spinner {
    /* Desacelerar muito ou parar completamente */
    animation-duration: 4s;
  }

  /* Opção nuclear — desativar TODAS as animações no site */
  /* Use isso apenas se você não tiver auditado cada animação individualmente */
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

A "opção nuclear" no final desse snippet é um padrão comum para codebases existentes que não auditaram cada animação. É melhor do que nada, mas auditar cada animação individualmente dá mais controle — algumas animações transmitem estado (uma barra de progresso, um spinner de carregamento) e devem ser mantidas, apenas desaceleradas.

Transições vs @keyframes — O Guia de Decisão

Se você não tiver certeza qual usar, faça duas perguntas sobre a animação que está construindo:

  • É disparada por uma mudança de estado? (hover, focus, toggle de classe, checkbox) → Use uma transição. É exatamente para isso que as transições foram projetadas.
  • Faz loop indefinidamente? → Use @keyframes com animation-iteration-count: infinite.
  • Tem mais de dois passos? (não apenas início → fim, mas início → meio → fim ou mais) → Use @keyframes.
  • Precisa rodar no carregamento da página, sem nenhuma interação do usuário? → Use @keyframes.
  • O elemento precisa ficar no estado final após terminar? → Use @keyframes com animation-fill-mode: forwards.
A combinação que pega as pessoas de surpresa: você quer um efeito hover que funde suavemente uma cor de fundo (transição), mas também quer um badge "novo" no mesmo elemento pulsando continuamente (@keyframes). Você pode usar ambos no mesmo elemento — eles controlam propriedades diferentes e não interferem entre si.

Conclusão

Transições lidam com movimento guiado por estado. @keyframes lida com todo o resto. Sempre anime transform e opacity para desempenho — deixe width, height, top e left de fora. Use animation-fill-mode: forwards sempre que precisar que o elemento mantenha seu estado final. Adicione o override de prefers-reduced-motion antes de lançar qualquer coisa — é uma adição de dez linhas que importa muito para usuários reais. Para mais sobre o modelo de composição e o que dispara layout, os docs de desempenho de renderização do web.dev são o recurso mais prático disponível. Depois de escrever o CSS, passe pelo CSS Formatter para manter o shorthand legível, ou pelo CSS Minifier para remover espaços em branco antes de lançar para produção.