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.
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.
@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.
@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:
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);
}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:
@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; }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:
.curto { animation: entrar 600ms; }
.longo { animation: entrar 600ms ease-out 200ms 3 alternate both 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:
@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; }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:
@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; }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.
@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; }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:
@keyframes piscar { to { opacity: 0 } }
.cursor { animation: piscar 1000ms steps(2, jump-none) infinite; }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.
@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 }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:
@keyframes girar { to { rotate: 360deg } }
@keyframes encolher { to { scale: 0 } }
.spinner {
animation:
girar 600ms linear infinite,
encolher 200ms linear 1200ms forwards;
}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.
<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>@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; }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:
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:
.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; }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`);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:
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}`);
}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.
// 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);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.
@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);
}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:
@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%;
}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.
.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; }
}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.
DevClub no YouTubeCOMO CRIAR UM LOADING SKELETON NO CSSAssistir a aula
DevClub no YouTubeAnimação no Scroll | Usando apenas HTML, CSS e JavaScriptAssistir a aula
Perguntas frequentes
Qual a diferença entre transition e animation?
Minha animação roda uma vez e o elemento volta ao normal. Por quê?
Dá para pausar uma animação sem remover a classe?
Preciso de JavaScript para animar quando o elemento entra na tela?
Quantas animações infinitas dá para deixar rodando numa página?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- MDN — Usando animações CSS — developer.mozilla.org
- MDN — animation-fill-mode — developer.mozilla.org
- CSS Animations Level 1 — W3C — w3.org
- CSS Scroll-driven Animations — W3C — w3.org
- MDN — prefers-reduced-motion — developer.mozilla.org


