Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

animation e @keyframes: animar CSS sem JavaScript

Como escrever uma animação em keyframes, controlar repetição, direção e preenchimento, e encadear várias sem depender de setTimeout no JavaScript.

Rodolfo Mori16 min de leitura

Uma animation é um roteiro de estados guardado num bloco @keyframes e um conjunto de regras de execução — quanto tempo dura, quantas vezes repete, para que lado corre e o que sobra na tela quando acaba. Diferente da transition, ela não espera gatilho nenhum: começa sozinha assim que o elemento entra na página e sabe passar por etapas no meio do caminho.

Todos os exemplos deste artigo são do Cine Íris, um cinema de rua que vende ingresso pelo site: o cartaz que sobe ao carregar, o aviso de últimas poltronas pulsando, o spinner do botão de compra, o skeleton da grade de sessões e a barra de progresso da programação. Cada saída colada aqui saiu de um Chromium 151.0.7922.34 dirigido por Playwright — quase sempre com a animação pausada e o relógio dela empurrado na mão, para o número não depender de quando o print foi tirado.

animation-duration animation-delay fase "antes" fase ativa fase "depois" backwards pinta aqui forwards pinta aqui from / 0% to / 100%

@keyframes: o roteiro separado da execução

O bloco @keyframes recebe um nome e descreve estados, não tempo. Quem transforma esses estados em movimento é a propriedade animation, aplicada ao elemento. Essa separação é o ponto: o mesmo roteiro serve para três elementos com durações diferentes.

css
@keyframes entrar {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: translateY(0); }
}

.cartaz {
  width: 200px;
  height: 300px;
  animation: entrar 600ms linear;
}

Para conferir o que acontece no meio, dá para pausar a animação pelo JavaScript e empurrar o relógio dela na mão. É assim que todas as medições deste artigo foram feitas:

js
const el = document.querySelector('.cartaz');
const anim = el.getAnimations()[0];
anim.pause();

for (const t of [0, 150, 300, 450, 600]) {
  anim.currentTime = t;
  const cs = getComputedStyle(el);
  console.log(t, cs.opacity, cs.transform);
}
0ms opacity 0.00 matrix(1, 0, 0, 1, 0, 24) 150ms opacity 0.25 matrix(1, 0, 0, 1, 0, 18) 300ms opacity 0.50 matrix(1, 0, 0, 1, 0, 12) 450ms opacity 0.75 matrix(1, 0, 0, 1, 0, 6) 600ms opacity 1.00 none

Repare na última linha: aos 600 ms a animação acabou e o transform voltou a ser none. Esse é o comportamento padrão, e ele tem nome — é a próxima armadilha desta lição.

As porcentagens não distribuem o tempo igualmente

from e to são apelidos de 0% e 100%. Entre eles cabe qualquer porcentagem, e é aí que a animação ganha ritmo. O aviso de “últimas poltronas” do Cine Íris cresce devagar e volta rápido:

css
@keyframes pulsar {
  0%   { transform: scale(1);    background: #d94f2b; }
  70%  { transform: scale(1.35); background: #f2a83b; }
  100% { transform: scale(1);    background: #d94f2b; }
}

.aviso { animation: pulsar 1000ms linear infinite; }
0ms scale 1.000 rgb(217, 79, 43) 350ms scale 1.175 rgb(230, 124, 51) 700ms scale 1.350 rgb(242, 168, 59) 850ms scale 1.175 rgb(230, 124, 51) 1000ms scale 1.000 rgb(217, 79, 43)

Aos 350 ms e aos 850 ms o valor é exatamente o mesmo, 1.175 — mas a subida levou 700 ms e a descida levou 300 ms. A porcentagem no @keyframes marca onde, não quanto: mover o 70% para 30% inverte o ritmo sem trocar uma vírgula na duração.

As oito partes escondidas no atalho animation

animation: entrar 600ms parece uma propriedade, mas é um atalho que preenche oito. Dá para ver todas elas pelo estilo computado:

css
.curto { animation: entrar 600ms; }
.longo { animation: entrar 600ms ease-out 200ms 3 alternate both paused; }
.curto animation-name entrar animation-duration 0.6s animation-timing-function ease animation-delay 0s animation-iteration-count 1 animation-direction normal animation-fill-mode none animation-play-state running .longo animation-name entrar animation-duration 0.6s animation-timing-function ease-out animation-delay 0.2s animation-iteration-count 3 animation-direction alternate animation-fill-mode both animation-play-state paused

Duas consequências práticas. A primeira: escrever o atalho reseta as oito — se você tinha animation-delay: 200ms numa regra anterior e depois escreve animation: entrar 600ms, o delay volta para zero. A segunda: quando aparecem dois tempos, o primeiro é sempre a duração e o segundo é sempre o delay, nessa ordem, não importa como você leu o valor na cabeça.

propriedade padrão o que ela resolve
animation-name none qual @keyframes executar
animation-duration 0s quanto tempo uma volta leva
animation-timing-function ease a curva dentro de cada trecho
animation-delay 0s quanto esperar antes de começar
animation-iteration-count 1 quantas voltas (ou infinite)
animation-direction normal para que lado cada volta corre
animation-fill-mode none o que fica na tela fora da janela
animation-play-state running rodando ou congelada

animation-fill-mode: por que o elemento volta ao estado inicial

Este é o campeão de dúvida. Fora da janela da animação — antes do delay terminar e depois da última volta — o navegador ignora os keyframes e usa o valor do CSS normal. O fill-mode é quem manda a animação “pintar” também nessas duas bordas.

O teste usa quatro parágrafos com opacity: 0.2 no CSS normal e uma animação que vai de 0 a 1, com 300 ms de delay e 400 ms de duração:

css
@keyframes entrar { from { opacity: 0 } to { opacity: 1 } }
p { opacity: 0.2; }

.nada      { animation: entrar 400ms linear 300ms none; }
.forwards  { animation: entrar 400ms linear 300ms forwards; }
.backwards { animation: entrar 400ms linear 300ms backwards; }
.both      { animation: entrar 400ms linear 300ms both; }
modo t=100ms t=500ms t=900ms nada 0.20 0.50 0.20 forwards 0.20 0.50 1.00 backwards 0.00 0.50 0.20 both 0.00 0.50 1.00

Leia a coluna do meio primeiro: aos 500 ms a animação está no ar em todos os casos, e todo mundo marca 0.50. As bordas é que separam os quatro. Aos 100 ms ainda estamos no delay: só backwards e both já mostram o from. Aos 900 ms já acabou: só forwards e both seguram o to.

iteration-count, direction e a volta que corta seco

Repetir é fácil: infinite ou um número. O que quase ninguém percebe é que uma animação repetida em normal volta ao início num salto. Três faixas correndo 120 px em 300 ms, medidas nos mesmos instantes:

css
@keyframes deslizar { from { transform: translateX(0) } to { transform: translateX(120px) } }

.normal    { animation: deslizar 300ms linear 4 normal    forwards; }
.reverse   { animation: deslizar 300ms linear 4 reverse   forwards; }
.alternate { animation: deslizar 300ms linear 3 alternate forwards; }
direction 0ms 150ms 300ms 450ms 600ms 750ms 900ms 1200ms normal 0px 60px 0px 60px 0px 60px 0px 120px reverse 120px 60px 120px 60px 120px 60px 120px 0px alternate 0px 60px 120px 60px 0px 60px 120px 120px

Na linha normal, aos 300 ms a faixa está em 0px de novo: ela chegou aos 120 px e cortou de volta para o começo. Na linha alternate, aos 300 ms ela está em 120px e no instante seguinte começa a voltar — o movimento é contínuo, sem corte. É por isso que quase todo efeito de “respiração” usa alternate.

E olhe o fim das três linhas, aos 1200 ms, com forwards segurando o último quadro. A alternate com 3 voltas termina em 120px; com 4 voltas terminaria em 0px. Em alternate, a paridade do iteration-count decide onde o elemento estaciona.

steps(): o cursor que pisca e a máquina de escrever

steps(n) troca a interpolação contínua por uma escada de n degraus. O letreiro do Cine Íris é o caso clássico: a largura do texto cresce de zero até 12 caracteres, e em steps(12) ela cresce de letra em letra.

css
@keyframes digitar { from { width: 0 } to { width: 12ch } }

p { font: 16px monospace; white-space: nowrap; overflow: hidden; }
.linear { animation: digitar 1200ms linear    forwards; }
.passos { animation: digitar 1200ms steps(12) forwards; }
timing 0ms 50ms 99ms 100ms 150ms 250ms 350ms linear 0px 4.79688px 9.5px 9.59375px 14.3906px 24px 33.5938px passos 0px 0px 0px 9.59375px 9.59375px 19.2031px 28.7969px

A linha linear muda a cada milissegundo. A linha passos fica parada em 0px até os 99 ms e pula direto para 9.59375px aos 100 ms — porque 1200 ms divididos por 12 degraus dão exatamente 100 ms por degrau, e cada degrau vale um caractere da fonte monoespaçada. Meia letra nunca aparece, e é isso que faz o efeito parecer digitação de verdade.

O mesmo mecanismo dá o cursor piscando. Com dois degraus e jump-none, a animação assume só os valores das duas pontas, metade do tempo em cada uma:

css
@keyframes piscar { to { opacity: 0 } }

.cursor { animation: piscar 1000ms steps(2, jump-none) infinite; }
cursor 0ms opacity 1 cursor 250ms opacity 1 cursor 499ms opacity 1 cursor 500ms opacity 0 cursor 750ms opacity 0 cursor 999ms opacity 0

Sem o steps(), esse mesmo @keyframes produziria um cursor que desaparece suavemente — que é bonito e não é um cursor. A mesma ideia move sprite: se a sua tira tem 8 quadros lado a lado, steps(8) sobre background-position troca de quadro sem mostrar dois pela metade.

Encadear animações com delay, em vez de setTimeout

A grade de sessões do Cine Íris tem cinco cartazes, e eles entram um depois do outro. A tentação é usar setTimeout para adicionar uma classe por vez. Não precisa: o mesmo @keyframes com delays diferentes resolve, e sem JavaScript nenhum.

css
@keyframes entrar { from { opacity: 0 } to { opacity: 1 } }

.cartaz { opacity: 0; animation: entrar 400ms linear both; }
.cartaz:nth-child(1) { animation-delay: 0ms }
.cartaz:nth-child(2) { animation-delay: 120ms }
.cartaz:nth-child(3) { animation-delay: 240ms }
.cartaz:nth-child(4) { animation-delay: 360ms }
.cartaz:nth-child(5) { animation-delay: 480ms }
tempo cartaz1 cartaz2 cartaz3 cartaz4 cartaz5 0ms 0.00 0.00 0.00 0.00 0.00 200ms 0.50 0.20 0.00 0.00 0.00 400ms 1.00 0.70 0.40 0.10 0.00 600ms 1.00 1.00 0.90 0.60 0.30 880ms 1.00 1.00 1.00 1.00 1.00

A cascata está toda ali, e o relógio é o do navegador — não o da fila de tarefas do JavaScript, que atrasa quando a página está ocupada. Com variáveis CSS você troca os cinco seletores por um só, lendo o índice de um style="--i: 3" no HTML.

Duas animações também podem se revezar no mesmo elemento. A propriedade animation aceita lista separada por vírgula, e o delay da segunda é o que marca a passagem de bastão:

css
@keyframes girar    { to { rotate: 360deg } }
@keyframes encolher { to { scale: 0 } }

.spinner {
  animation:
    girar    600ms linear infinite,
    encolher 200ms linear 1200ms forwards;
}
girar delay 0ms duracao 600ms repete Infinity encolher delay 1200ms duracao 200ms repete 1

0ms rotate 0deg scale none 300ms rotate 180deg scale none 900ms rotate 180deg scale none 1200ms rotate 0deg scale 1 1300ms rotate 60deg scale 0.5 1400ms rotate 120deg scale 0

O spinner gira sozinho e, ao completar 1200 ms, encolhe até sumir. Repare que scale aparece como none antes disso: o forwards da segunda animação só vale depois do fim dela, nunca durante o delay — de novo o fill-mode mandando na cena.

Um skeleton loader do zero, keyframe por keyframe

Skeleton é o retângulo cinza que ocupa o lugar do conteúdo enquanto a resposta não chega. O truque inteiro é um gradiente maior que a caixa, deslizando por ela. Nada de imagem, nada de biblioteca.

html
<div class="cartao" aria-busy="true">
  <div class="esqueleto esqueleto--cartaz"></div>
  <div class="esqueleto esqueleto--titulo"></div>
  <div class="esqueleto esqueleto--horario"></div>
</div>
css
@keyframes brilho {
  from { background-position: 100% 0; }
  to   { background-position: -100% 0; }
}

.esqueleto {
  background-color: #e6e6ea;
  background-image: linear-gradient(90deg, transparent 0%, #f7f7fa 50%, transparent 100%);
  background-size: 200% 100%;
  background-repeat: no-repeat;
  border-radius: 6px;
  animation: brilho 1400ms linear infinite;
}

.esqueleto--cartaz  { width: 180px; height: 260px; }
.esqueleto--titulo  { width: 180px; height: 14px; margin-top: 12px; }
.esqueleto--horario { width: 110px; height: 12px; margin-top: 8px; }
animacoes na tela: 3

0ms background-position 100% 0px 350ms background-position 50% 0px 700ms background-position 0% 0px 1050ms background-position -50% 0px 1399ms background-position -99.8571% 0px

Três elementos, três animações independentes, zero linha de JavaScript. O background-size: 200% 100% é o que dá espaço para o gradiente correr — e aqui mora a parte que engana: o número cai de 100% para -100%, mas o brilho atravessa da esquerda para a direita. Com a imagem duas vezes mais larga que a caixa, background-position: 100% encosta a borda direita da imagem na borda direita da caixa, e isso joga a faixa clara — que mora no meio da imagem — para o canto esquerdo.

Dá para conferir sem confiar no olho. Trocando o cinza por preto e a faixa por branco só para o número ficar legível, e medindo o brilho médio de cinco fatias da esquerda para a direita, no meio da altura:

t= 0ms brilho por fatia (esq->dir): 229 178 127 76 25 t= 350ms brilho por fatia (esq->dir): 153 204 242 204 153 t= 700ms brilho por fatia (esq->dir): 25 76 127 178 229 t=1050ms brilho por fatia (esq->dir): 0 0 6 51 102 t=1399ms brilho por fatia (esq->dir): 0 0 0 0 0

No instante zero o lado claro é o esquerdo; aos 350 ms a faixa está centrada; aos 700 ms ela encostou na direita; depois disso sai de cena e a caixa fica uniforme até o loop recomeçar. Se você quiser entender de onde vem esse linear-gradient, ele é o mesmo assunto de background, border-radius e box-shadow.

Quando os dados chegam, o JavaScript faz uma coisa só: troca aria-busy para false e substitui o conteúdo. A animação some junto com os elementos, sem clearInterval para lembrar.

O erro mais comum: a animação que simplesmente não roda

Três formas de escrever uma animação que não anima, lado a lado com a versão correta:

css
.sem-duracao { animation: girar linear infinite; }
.dois-tempos { animation: girar 900ms 200ms linear infinite; }
.nome-errado { animation: girrar 900ms linear infinite; }
.ok          { animation: girar 900ms linear infinite; }
js
for (const c of ['sem-duracao', 'dois-tempos', 'nome-errado', 'ok']) {
  const el = document.querySelector('.' + c);
  const cs = getComputedStyle(el);
  console.log(
    `.${c.padEnd(12)} name ${cs.animationName.padEnd(8)} duration ${cs.animationDuration.padEnd(6)}` +
      ` delay ${cs.animationDelay.padEnd(5)} · getAnimations() ${el.getAnimations().length}`
  );
}
console.log(`\ndocument.getAnimations(): ${document.getAnimations().length} animacoes ativas`);
.sem-duracao name girar duration 0s delay 0s · getAnimations() 0 .dois-tempos name girar duration 0.9s delay 0.2s · getAnimations() 1 .nome-errado name girrar duration 0.9s delay 0s · getAnimations() 0 .ok name girar duration 0.9s delay 0s · getAnimations() 1

document.getAnimations(): 2 animacoes ativas

E o que está na tela aos 450 ms. De novo com o relógio parado na mão, para o ângulo não depender de quão rápido a sua máquina chegou até ali:

js
for (const c of ['sem-duracao', 'dois-tempos', 'nome-errado', 'ok']) {
  const el = document.querySelector('.' + c);
  for (const a of el.getAnimations()) { a.pause(); a.currentTime = 450; }
  console.log(`.${c.padEnd(12)} rotate ${getComputedStyle(el).rotate}`);
}
.sem-duracao rotate none .dois-tempos rotate 100deg .nome-errado rotate none .ok rotate 180deg

Os três defeitos são de naturezas diferentes, e o navegador não reclama de nenhum. Sem duração, o padrão é 0s e a animação nem chega a existir — getAnimations() devolve zero. Com o nome errado, a propriedade aceita girrar numa boa, porque nome de animação é texto livre; só não existe @keyframes com esse nome, e de novo não nasce animação alguma. E o .dois-tempos roda, mas 200 ms atrasado: aos 450 ms ele está em 100°, enquanto o correto já está em 180° — os 80° de diferença são exatamente os 200 ms de delay convertidos em ângulo. Nos três casos o console fica limpo: CSS não avisa erro, ele descarta em silêncio.

24 spinners: CSS contra requestAnimationFrame

Aqui está a razão de fundo para preferir @keyframes a um laço em JavaScript. Montei a mesma sala de espera do Cine Íris com 24 spinners e animei ela de duas formas: uma com animation: girar 900ms linear infinite, outra com um requestAnimationFrame escrevendo el.style.rotate em todos os 24 a cada quadro.

js
// a versao em JavaScript
function tick(t) {
  const graus = ((t % 900) / 900) * 360;
  for (const el of spinners) el.style.rotate = graus + 'deg';
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
Chromium 151.0.7922.34 headless · MacBook · 24 spinners na mesma tela

A) 3 segundos girando, com a pagina livre — media de 5 execucoes CSS 359 quadros · 0.6ms de thread principal · 0.002ms por quadro JS 361 quadros · 8.0ms de thread principal · 0.022ms por quadro

B) thread principal travada por 1200ms — quadros que o Chromium pintou mesmo assim CSS 99 quadros pintados · 99 imagens diferentes JS 0 quadros pintados · 0 imagens diferentes

O teste A é o cenário bom, e nele os dois empatam em fluidez: 359 e 361 quadros em três segundos. O que muda é a fatura — 8 ms de thread principal contra praticamente nada. Os 0,6 ms da linha do CSS são o custo de medir, não de animar.

O teste B é o cenário real. Travei a thread principal por 1200 ms com um laço síncrono — o equivalente a um JSON grande sendo processado ou a uma re-renderização pesada — e gravei a tela por fora, com o Page.startScreencast do protocolo de depuração do Chromium, que recebe os quadros do compositor e não depende do JavaScript da página. Nesse intervalo o spinner de CSS foi pintado 99 vezes, com 99 imagens diferentes. O spinner de requestAnimationFrame foi pintado zero vezes — ele congelou, porque o próximo quadro dele estava na fila atrás do laço.

Isso não é otimização de detalhe. transform e opacity são entregues ao compositor, que roda em outra thread e continua trabalhando enquanto o JavaScript está ocupado. É justamente quando a página está lenta que o indicador de carregamento precisa continuar girando — e é exatamente aí que a versão em JavaScript para.

Animação ligada ao scroll, sem IntersectionObserver

A barra de progresso da programação do Cine Íris não tem duração em milissegundos: ela tem uma linha do tempo de rolagem. Basta trocar o relógio da animação com animation-timeline.

css
@keyframes preencher { from { transform: scaleX(0) } to { transform: scaleX(1) } }

.progresso {
  position: fixed; top: 0; left: 0;
  height: 4px; width: 100%;
  background: #d94f2b;
  transform-origin: 0 50%;
  animation: preencher linear;
  animation-timeline: scroll(root block);
}
animation-timeline: scroll() suportado? true rolagem disponivel: 2400px

scrollY 0px -> scaleX 0.000 currentTime 0% scrollY 600px -> scaleX 0.250 currentTime 25% scrollY 1200px -> scaleX 0.500 currentTime 50% scrollY 1800px -> scaleX 0.750 currentTime 75% scrollY 2400px -> scaleX 1.000 currentTime 100%

Olhe o currentTime: ele deixou de ser milissegundo e virou porcentagem. A animação não “roda”, ela é posicionada pela rolagem — se você rolar para cima, ela anda para trás.

A variante view() usa a posição do próprio elemento dentro da viewport, que é o que se costuma resolver com IntersectionObserver:

css
@keyframes surgir { from { opacity: 0; scale: 0.9 } to { opacity: 1; scale: 1 } }

.cartaz {
  animation: surgir linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
viewport de 400px de altura · cartaz de 200px

scrollY 0px topo do cartaz a 600px do alto -> opacity 0.00 scale 0.9 scrollY 200px topo do cartaz a 400px do alto -> opacity 0.00 scale 0.9 scrollY 300px topo do cartaz a 300px do alto -> opacity 0.50 scale 0.95 scrollY 400px topo do cartaz a 200px do alto -> opacity 1.00 scale 1 scrollY 500px topo do cartaz a 100px do alto -> opacity 1.00 scale 1

O intervalo entry tem começo e fim exatos, e os dois aparecem aí. Ele abre quando o topo do cartaz encosta na borda de baixo da viewport — topo em 400 px, opacity 0 — e fecha quando o cartaz acabou de entrar inteiro — topo em 200 px, opacity 1. Os 200 px entre um ponto e outro são a altura do próprio cartaz. No meio do caminho, topo em 300 px, o navegador entrega 0.50: sem observador, sem listener de scroll, sem requestAnimationFrame.

Parar tudo com prefers-reduced-motion

Existe gente para quem movimento repetido na tela provoca enjoo de verdade, e essas pessoas ligam a opção no sistema operacional. Em animação infinita a resposta certa não é encurtar a duração — é desligar o que não carrega informação e desacelerar o que carrega.

css
.esqueleto { animation: brilho 1400ms linear infinite; }
.spinner   { animation: girar   700ms linear infinite; }

@media (prefers-reduced-motion: reduce) {
  .esqueleto { animation: none; background-position: 0 0; }
  .spinner   { animation-duration: 2400ms; }
}
prefers-reduced-motion: no-preference media query casa? false .esqueleto brilho / 1.4s .spinner girar / 0.7s animacoes rodando 2 prefers-reduced-motion: reduce media query casa? true .esqueleto none / 0s .spinner girar / 2.4s animacoes rodando 1

O brilho do skeleton sumiu inteiro — document.getAnimations() caiu de 2 para 1 — e o retângulo cinza continua ali cumprindo o papel dele, que é reservar o espaço. O spinner ficou, porque ele é a única prova de que a compra está sendo processada — mas a volta dele passou de 700 ms para 2,4 s, um giro lento em vez de um pião. Se você nunca mexeu com esse bloco, media queries no CSS explica como o navegador escolhe entre as duas versões da regra.

O que treinar agora

Pegue o loading do seu projeto e faça três coisas nesta ordem. Troque qualquer laço de setInterval que muda estilo por um @keyframes com infinite. Confira no console que document.getAnimations() devolve o número de animações que você espera, nem uma a mais. E feche com o bloco de prefers-reduced-motion desligando o que for decorativo.

Se a sua animação precisa de um gatilho — hover, foco, clique — a ferramenta é outra, e ela está em transition e transform no CSS, a lição anterior desta trilha. Para ver onde este assunto entra no percurso inteiro, o guia de CSS mostra a ordem completa, e a trilha de CSS lista as lições na sequência.

Prefere aprender em vídeo?

Tem aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • css
  • animation
  • keyframes
  • loading
  • scroll

Perguntas frequentes

Qual a diferença entre transition e animation?
A transition precisa de um gatilho e só sabe ir de um estado para outro. A animation roda sozinha assim que o elemento existe, aceita etapas intermediárias e sabe repetir. Loading, skeleton e badge pulsando são animation; hover e foco são transition.
Minha animação roda uma vez e o elemento volta ao normal. Por quê?
Porque o padrão de animation-fill-mode é none, e fora da janela da animação o navegador volta a usar o valor do CSS normal. Use forwards para segurar o último keyframe depois do fim, ou both para segurar também o primeiro durante o delay.
Dá para pausar uma animação sem remover a classe?
Dá, com animation-play-state paused. O elemento congela exatamente no ponto em que estava e volta do mesmo ponto quando você devolve running. É o jeito de parar um carrossel no hover sem perder a posição.
Preciso de JavaScript para animar quando o elemento entra na tela?
Não em navegador atual. animation-timeline com view() liga a animação à posição do elemento dentro da viewport, sem IntersectionObserver. Em navegador antigo a regra é ignorada, então declare o estado final no CSS normal para a página continuar legível.
Quantas animações infinitas dá para deixar rodando numa página?
Enquanto forem de transform e opacity, muitas — elas rodam no compositor e não custam thread principal. O problema não é a quantidade, é o tipo de propriedade: animar width, height, top ou left obriga o navegador a recalcular layout a cada quadro.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Chromium 151.0.7922.34 via Playwright, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Usando animações CSS — developer.mozilla.org
  2. MDN — animation-fill-mode — developer.mozilla.org
  3. CSS Animations Level 1 — W3C — w3.org
  4. CSS Scroll-driven Animations — W3C — w3.org
  5. MDN — prefers-reduced-motion — developer.mozilla.org

Continue por aqui