transition e transform no CSS: o hover que desliza
Como mover, girar e escalar sem afetar o layout, e como animar essa mudança com transition — incluindo quais propriedades são baratas de animar.
transform muda a aparência do elemento na tela — move, gira, aumenta — sem
mudar o lugar que ele ocupa no layout. transition faz essa mudança acontecer
ao longo do tempo, em vez de num salto. Juntas, as duas produzem quase todo o
movimento que você vê numa interface bem-feita.
Todos os exemplos deste artigo são da Livraria Página Sete, uma loja de livros online: o cartão que sobe no hover, a gaveta do carrinho que entra pela direita, o selo de desconto girado e a sinopse que abre. Cada saída colada aqui saiu de um Chromium 151 dirigido por Playwright, e o script de medição vem logo antes do resultado.
transform move o pixel, não a caixa
Esta é a frase que explica tudo o que vem depois. O navegador calcula o layout
primeiro — quem fica onde, com que tamanho — e só depois aplica o transform
na hora de desenhar. O resultado é que o elemento aparece deslocado, mas o
buraco que ele ocupava continua exatamente do mesmo tamanho, no mesmo lugar.
<div class="prateleira">
<div class="livro" id="destaque">Dom Casmurro</div>
<div class="livro" id="vizinho">Grande Sertão</div>
</div>* { box-sizing: border-box; }
body { margin: 0; }
.prateleira { display: flex; gap: 20px; padding: 40px; }
.livro { width: 180px; height: 260px; border: 1px solid #999; }
#destaque { transform: translateY(-24px) scale(1.08); }Agora a medição. offsetTop e offsetWidth respondem pela caixa do
layout; getBoundingClientRect() responde pelo retângulo desenhado.
const d = document.getElementById('destaque');
const v = document.getElementById('vizinho');
const r = d.getBoundingClientRect();
console.log({
offsetLeft: d.offsetLeft, offsetTop: d.offsetTop,
offsetWidth: d.offsetWidth, offsetHeight: d.offsetHeight,
rectTop: r.top, rectLeft: r.left,
rectWidth: r.width, rectHeight: r.height,
vizinhoLeft: v.offsetLeft,
vizinhoRectLeft: v.getBoundingClientRect().left,
});Leia com calma, porque tem tudo aí. offsetTop continua 40 — o layout não
soube de nada. Mas o retângulo desenhado começa em 5.6: subiu os 24 px do
translateY e mais 10,4 px porque o scale(1.08) esticou os 260 px de altura
para os dois lados. E o vizinho? offsetLeft e o retângulo real dão os
mesmos 240. Ele não se mexeu um pixel.
Compare com position: relative e left, que também deslocam sem tirar o
elemento do fluxo, mas passam pelo cálculo de layout de novo a cada mudança —
o assunto de position no CSS. É essa diferença que a
seção da medição, mais adiante, transforma em número.
translate, scale e rotate — e por que a ordem muda o resultado
transform aceita uma lista de funções, aplicadas da direita para a
esquerda. Na prática o que importa é outra coisa: cada função gira ou move o
próprio sistema de coordenadas do elemento, e as funções seguintes trabalham
dentro do sistema já modificado.
.capa { position: absolute; top: 100px; left: 100px; width: 100px; height: 100px; }
#a { transform: translateX(200px) rotate(45deg); }
#b { transform: rotate(45deg) translateX(200px); }<div class="capa" id="a"></div>
<div class="capa" id="b"></div>const medir = (id) => {
const r = document.getElementById(id).getBoundingClientRect();
return {
left: +r.left.toFixed(2), top: +r.top.toFixed(2),
centroX: +(r.left + r.width / 2).toFixed(2),
centroY: +(r.top + r.height / 2).toFixed(2),
};
};
console.log({
'translateX(200px) rotate(45deg)': medir('a'),
'rotate(45deg) translateX(200px)': medir('b'),
});Os dois elementos nasceram no mesmo lugar e receberam as mesmas duas funções.
O primeiro terminou com o centro em (350, 150): andou 200 px para a direita e
girou parado ali. O segundo terminou em (291.42, 291.42): girou 45° primeiro,
e o translateX seguinte empurrou 200 px na diagonal, porque o eixo X do
elemento também tinha girado.
transform-origin: o ponto em que a peça gira
Toda transformação acontece em torno de um ponto. Por padrão esse ponto é o
centro do elemento; transform-origin muda isso. É o que decide se o selo de
desconto gira no lugar ou balança pendurado por um canto.
.selo {
position: absolute; top: 200px; left: 200px;
width: 120px; height: 40px;
transform: rotate(30deg);
}
#centro { transform-origin: center; }
#esquerda { transform-origin: left center; }
#canto { transform-origin: top left; }<div class="selo" id="centro"></div>
<div class="selo" id="esquerda"></div>
<div class="selo" id="canto"></div>const caixa = (id) => {
const el = document.getElementById(id);
const r = el.getBoundingClientRect();
return {
origin: getComputedStyle(el).transformOrigin,
left: +r.left.toFixed(2), top: +r.top.toFixed(2),
largura: +r.width.toFixed(2), altura: +r.height.toFixed(2),
};
};
console.log({
centro: caixa('centro'),
'left center': caixa('esquerda'),
'top left': caixa('canto'),
});Repare que os três retângulos têm exatamente o mesmo tamanho — 123.92 por
94.64, que é a caixa que um retângulo de 120×40 ocupa depois de girar 30°.
O que muda é onde essa caixa cai. E repare também que center virou
60px 20px no computed style: o navegador resolve a palavra para pixels,
metade da largura e metade da altura.
As propriedades separadas: translate, rotate e scale
Desde 2022 os três navegadores principais aceitam três propriedades
independentes com esses nomes. Elas existem porque animar só a rotação de um
elemento que já está deslocado, usando o atalho transform, obriga a repetir o
deslocamento em todo lugar. Com as propriedades separadas, cada eixo vive
sozinho.
E tem uma diferença importante: nas propriedades separadas a ordem de
declaração não importa. O navegador sempre aplica translate, depois
rotate, depois scale.
.capa { position: absolute; top: 100px; left: 100px; width: 100px; height: 100px; }
/* declaradas de propósito na ordem errada */
#individual { scale: 1.5; rotate: 45deg; translate: 200px 0; }
#atalho { transform: scale(1.5) rotate(45deg) translateX(200px); }
#atalho2 { transform: translateX(200px) rotate(45deg) scale(1.5); }Medindo a matriz computada e o centro real de cada um dos três:
const inspecionar = (id) => {
const el = document.getElementById(id);
const r = el.getBoundingClientRect();
return {
matriz: getComputedStyle(el).transform,
centroX: +(r.left + r.width / 2).toFixed(2),
centroY: +(r.top + r.height / 2).toFixed(2),
};
};
console.log({
'scale/rotate/translate separadas': inspecionar('individual'),
'transform: scale rotate translateX': inspecionar('atalho'),
'transform: translateX rotate scale': inspecionar('atalho2'),
});As propriedades separadas deram (350, 150) — idêntico ao atalho escrito na
ordem translate → rotate → scale, e diferente do atalho escrito ao contrário.
Anote também o primeiro campo: getComputedStyle(el).transform devolveu
"none". Quem usa as propriedades separadas some do transform computado, e
quem for depurar precisa ler translate, rotate e scale um a um.
transition: propriedade, duração, curva e atraso
transition é um atalho de quatro valores, nesta ordem: qual propriedade
animar, quanto tempo, com que curva e depois de quanto tempo começar.
.cartao {
transform: translateY(0);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.15);
transition:
transform 180ms cubic-bezier(0.2, 0, 0, 1) 0ms,
box-shadow 180ms linear 0ms;
}
.cartao:hover {
transform: translateY(-8px);
box-shadow: 0 12px 28px rgb(0 0 0 / 0.22);
}O quarto valor, o atraso, é o que transforma quatro elementos que aparecem juntos numa cascata. Na gaveta do carrinho da livraria, cada item recebe um atraso próprio por variável CSS:
.item {
opacity: 0;
transform: translateX(24px);
transition:
opacity 240ms linear var(--atraso),
transform 240ms linear var(--atraso);
}
.gaveta.aberta .item { opacity: 1; transform: translateX(0); }<div class="gaveta" id="g">
<div class="item" style="--atraso: 0ms">Dom Casmurro</div>
<div class="item" style="--atraso: 80ms">Grande Sertão: Veredas</div>
<div class="item" style="--atraso: 160ms">Vidas Secas</div>
<div class="item" style="--atraso: 240ms">A Hora da Estrela</div>
</div>Para ver a cascata em número, dá para gravar a opacidade de cada item a cada
quadro, com requestAnimationFrame:
const itens = [...document.querySelectorAll('.item')];
const t0 = performance.now();
document.getElementById('g').classList.add('aberta');
function passo() {
const t = performance.now() - t0;
console.log(Math.round(t), itens.map((el) => getComputedStyle(el).opacity));
if (t < 560) requestAnimationFrame(passo);
}
requestAnimationFrame(passo);Formatando os quadros gravados como tabela, e ficando com o primeiro quadro depois de cada marca de 100 ms:
Aos 200 ms o primeiro item já andou 80% do caminho, o segundo não chegou na metade, o terceiro mal saiu do zero e o quarto ainda nem começou — o atraso dele é justamente 240 ms. Os tempos não são redondos porque cada linha é um quadro real, e os valores ficam cerca de um quadro atrás da conta pura: a transição só nasce no primeiro recálculo de estilo depois da troca de classe, não no instante em que o cronômetro do JavaScript zerou. Na conta do CSS a gaveta inteira leva 480 ms: os 240 ms de atraso do último item mais os 240 ms de duração dele. Cada item, sozinho, dura só 240 ms.
A curva: por que ease-in-out deixa o hover com cara de lento
transition aceita as palavras linear, ease, ease-in, ease-out,
ease-in-out — e cubic-bezier(), que é o que todas elas são por baixo. A
curva não muda a duração; muda quanto do caminho já foi andado em cada
instante.
Para medir isso sem depender de cronômetro, dá para pausar a transição e posicionar o relógio dela na mão, com a Web Animations API:
.capa { position: absolute; left: 0; width: 120px; height: 40px; transform: translateX(0); }
#linear { top: 0px; transition: transform 300ms linear; }
#ease { top: 60px; transition: transform 300ms ease; }
#easeinout { top: 120px; transition: transform 300ms ease-in-out; }
#easeout { top: 180px; transition: transform 300ms ease-out; }
#devclub { top: 240px; transition: transform 300ms cubic-bezier(0.2, 0, 0, 1); }
.aberto { transform: translateX(300px); }const ids = ['linear', 'ease', 'easeinout', 'easeout', 'devclub'];
const els = ids.map((id) => document.getElementById(id));
els.forEach((el) => el.classList.add('aberto'));
const anims = els.map((el) => el.getAnimations()[0]);
anims.forEach((a) => a.pause());
for (const t of [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300]) {
anims.forEach((a) => (a.currentTime = t));
console.log(t, els.map((el) => {
const m = new DOMMatrix(getComputedStyle(el).transform);
return +((m.m41 / 300) * 100).toFixed(1);
}));
}São cinco curvas na mesma transição, de translateX(0) até translateX(300px)
em 300 ms. Transpondo o resultado — uma linha por curva, uma coluna por
instante — o percentual do caminho já percorrido fica assim:
A linha easeinout explica a queixa. Nos primeiros 30 ms — os dois primeiros
quadros — ele andou 2% do caminho. O ease-out andou 16,1% no mesmo
intervalo, e a curva personalizada cubic-bezier(0.2, 0, 0, 1) andou 15,6%.
Como o olho julga a resposta pelos primeiros quadros, o ease-in-out parece
que travou, mesmo tendo exatamente a mesma duração.
A conta que decide: 37 Layouts contra zero
Aqui está o motivo real de a comunidade insistir em transform. Montei uma
página com o catálogo da livraria — 600 cartões de livro — e uma faixa de
“frete grátis” que se desloca 200 px em 300 ms. Depois gravei um trace do
Chromium durante 400 ms, em quatro versões que produzem o mesmo movimento
visual ou o mesmo efeito, mudando só a propriedade animada:
/* versão 1 — layout */
.faixa { position: relative; left: 0; transition: left 300ms linear; }
.faixa.correndo { left: 200px; }
/* versão 2 — pintura */
.faixa { box-shadow: 0 0 0 rgb(0 0 0 / 0.4); transition: box-shadow 300ms linear; }
.faixa.correndo { box-shadow: 0 18px 40px rgb(0 0 0 / 0.4); }
/* versão 3 — composição */
.faixa { opacity: 1; transition: opacity 300ms linear; }
.faixa.correndo { opacity: 0.2; }
/* versão 4 — composição */
.faixa { transform: translateX(0); transition: transform 300ms linear; }
.faixa.correndo { transform: translateX(200px); }O trace é ligado pelo protocolo do DevTools e os eventos são contados por nome:
// page é a Page do Playwright, já com a faixa e os 600 cartões na tela
const cdp = await page.context().newCDPSession(page);
const eventos = [];
cdp.on('Tracing.dataCollected', ({ value }) => eventos.push(...value));
await cdp.send('Tracing.start', {
transferMode: 'ReportEvents',
traceConfig: {
recordMode: 'recordAsMuchAsPossible',
includedCategories: [
'devtools.timeline',
'disabled-by-default-devtools.timeline',
'blink.user_timing',
],
},
});
await page.evaluate(() => {
performance.mark('inicio');
document.getElementById('faixa').classList.add('correndo');
});
await page.waitForTimeout(400);
await page.evaluate(() => performance.mark('fim'));
await cdp.send('Tracing.end');As duas marcas de performance recortam a janela, e aí é só contar os eventos
por nome e somar a duração de cada um:
const marca = Object.fromEntries(
eventos.filter((e) => e.cat.includes('blink.user_timing')).map((e) => [e.name, e.ts]),
);
const dentro = eventos.filter((e) => e.ph === 'X' && e.ts >= marca.inicio && e.ts <= marca.fim);
for (const nome of ['Layout', 'UpdateLayoutTree', 'Paint', 'PrePaint', 'Commit']) {
const lista = dentro.filter((e) => e.name === nome);
const ms = lista.reduce((soma, e) => soma + (e.dur || 0), 0) / 1000;
console.log(nome.padEnd(18), lista.length, ms.toFixed(1) + ' ms');
}Rodando as quatro versões e juntando o resultado numa tabela só:
Leia a última coluna primeiro: é a duração somada desses cinco eventos de
renderização na thread principal, dentro daqueles 400 ms. Animar left custou
30,2 ms; animar transform custou 1,4 ms. Vinte vezes menos, para o
mesmo movimento.
E olhe o box-shadow: zero eventos de Layout, porque sombra não muda
geometria — e ainda assim 48,5 ms, o pior de todos, porque cada um dos 70
Paint teve que redesenhar um borrão grande. Animar sombra é caro mesmo sem
mexer no layout, e é por isso que a lista de propriedades baratas é curta:
transform e opacity, e só.
As duas últimas linhas contam a outra metade da história. transform e
opacity produziram 8 eventos de Commit na janela inteira, contra 38 do
left. Não é que o trabalho foi mais rápido: é que ele nem chegou à thread
principal. O Chromium promove o elemento a uma camada própria e deixa o
compositor interpolar os quadros sozinho, no lado de lá.
Abrir a sinopse de zero até auto
O caso que todo mundo tenta e não funciona: a sinopse do livro escondida com
height: 0 e aberta com height: auto. O navegador não sabe interpolar entre
um número e uma palavra-chave, então o acordeão simplesmente pula.
.sinopse {
width: 400px;
font: 16px/1.5 system-ui;
height: 0;
overflow: hidden;
transition: height 300ms ease-out;
}
.sinopse p { margin: 0; }
.sinopse.aberta { height: auto; }<div class="sinopse">
<p>Bentinho e Capitu crescem juntos no Rio de Janeiro do século XIX. O
narrador conta a história décadas depois, já convencido de uma traição que
nunca prova.</p>
</div>const el = document.querySelector('.sinopse');
const alturaInicial = getComputedStyle(el).height;
el.classList.add('aberta');
const anims = el.getAnimations();
const amostras = [];
if (anims.length) {
anims[0].pause();
for (const t of [0, 75, 150, 225, 300]) {
anims[0].currentTime = t;
amostras.push(`${t}ms=${getComputedStyle(el).height}`);
}
anims[0].finish();
}
console.log({
alturaInicial,
animacoesCriadas: anims.length,
amostras,
alturaFinal: getComputedStyle(el).height,
});Zero animações criadas. A altura saltou direto de 0px para 72px, sem nada
entre as duas — e é exatamente isso que se vê na tela.
A propriedade interpolate-size: allow-keywords autoriza o navegador a
resolver auto para o tamanho real e interpolar até lá. Ela é herdada, então
declarar uma vez no :root vale para a página inteira:
:root { interpolate-size: allow-keywords; }Agora existe uma animação, e a altura passa por 27.22, 49.28 e 65.27 no
caminho — o acordeão abre de verdade. A função calc-size(auto, size) no valor
final produz o mesmo resultado sem ligar o comportamento na página inteira,
o que é útil quando você não controla o :root:
.sinopse.aberta { height: calc-size(auto, size); }Antes de usar em produção, confirme o suporte — e programe a degradação. Sem
interpolate-size, o pior que acontece é o acordeão voltar a pular, o que é
aceitável:
@supports (interpolate-size: allow-keywords) {
:root { interpolate-size: allow-keywords; }
}Os dois erros que mais aparecem
O primeiro é declarar o transition dentro do :hover. A animação funciona na
entrada e some na saída, porque quando o cursor sai a regra :hover deixa de
valer — e junto com ela some a instrução de animar.
/* errado: o transition mora dentro do :hover */
.livro:hover { transform: translateY(-8px); transition: transform 300ms ease-out; }
/* certo: o transition mora na regra base */
.certo { transition: transform 300ms ease-out; }
.certo:hover { transform: translateY(-8px); }<div class="livro" id="errado">Dom Casmurro</div>
<div class="certo" id="certo">Grande Sertão: Veredas</div>Dá para provar contando quantas transições cada elemento tem rodando ao entrar e ao sair com o mouse:
// page é a Page do Playwright, com o HTML acima carregado
for (const [rotulo, seletor] of [
['transition dentro do :hover', '#errado'],
['transition na regra base', '#certo'],
]) {
const el = page.locator(seletor);
await el.hover();
const entrando = await el.evaluate((e) => e.getAnimations().length);
await page.mouse.move(800, 550); // tira o cursor de cima
const saindo = await el.evaluate((e) => e.getAnimations().length);
console.log(`${rotulo.padEnd(34)} entrando: ${entrando} transicao(oes) saindo: ${saindo} transicao(oes)`);
}Um contra zero na saída. O elemento errado sobe suavemente e desaba de volta num quadro só. Para entender por que a regra some junto com o estado, vale revisar pseudo-classes e pseudo-elementos.
O segundo erro é aplicar transform num elemento inline. A especificação
simplesmente não permite: transform não vale para elemento inline
não-substituído, e um <span> de texto é exatamente isso.
body { font: 16px system-ui; }
#girado { transform: rotate(-6deg) scale(1.4); }
#girado2 { display: inline-block; transform: rotate(-6deg) scale(1.4); }<p>Promoção: <span id="cru">-40%</span> até domingo.</p>
<p>Promoção: <span id="girado">-40%</span> até domingo.</p>
<p>Promoção: <span id="girado2">-40%</span> até domingo.</p>Medindo o retângulo desenhado dos três, largura por altura:
const medida = (id) => {
const el = document.getElementById(id);
const r = el.getBoundingClientRect();
return `${getComputedStyle(el).display.padEnd(13)} ${r.width.toFixed(2)} x ${r.height.toFixed(2)}`;
};
for (const [rotulo, id] of [
['sem transform', 'cru'],
['com transform', 'girado'],
['com inline-block', 'girado2'],
]) {
console.log(rotulo.padEnd(18) + medida(id));
}O <span> girado mede exatamente o mesmo que o <span> sem transform
nenhum: 41.48 x 18.00. Com display: inline-block, o retângulo cresce para
60.39 x 31.13, que é o tamanho de quem realmente girou e aumentou. O detalhe
cruel é que getComputedStyle(el).transform devolve a matriz completa nos dois
casos — o computed style mente, o retângulo não.
prefers-reduced-motion não é enfeite de acessibilidade
Existe gente para quem movimento na tela provoca enjoo e enxaqueca de verdade, e essas pessoas ligam a opção do sistema operacional. O navegador entrega isso para o CSS numa media query, e respeitá-la custa três linhas.
.cartao {
transition:
transform 320ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 320ms linear;
}
@media (prefers-reduced-motion: reduce) {
.cartao { transition-duration: 1ms; }
}O Playwright liga e desliga a preferência do sistema, e aí é só ler o estilo computado nos dois estados:
const ler = () => page.evaluate(() => {
const s = getComputedStyle(document.querySelector('.cartao'));
return {
matches: matchMedia('(prefers-reduced-motion: reduce)').matches,
'transition-duration': s.transitionDuration,
'transition-property': s.transitionProperty,
};
});
await page.emulateMedia({ reducedMotion: 'no-preference' });
console.log('no-preference ->', JSON.stringify(await ler()));
await page.emulateMedia({ reducedMotion: 'reduce' });
console.log('reduce ->', JSON.stringify(await ler()));Repare no que foi feito: a duração caiu para 1ms, não para 0. A diferença
importa porque um transitionend que nunca dispara trava código JavaScript que
espera pelo fim da animação. Com 1ms, o estado final chega imediatamente e o
evento continua acontecendo.
Note também que a duração encurtou para as duas propriedades de uma vez, sem
você precisar repetir a lista — transition-duration com um valor só se aplica
a todas. Se quiser ir mais fundo em como o navegador escolhe entre os dois
blocos, o caminho é media queries no CSS.
O que treinar agora
Pegue um cartão qualquer do seu projeto e faça três coisas, nesta ordem: mova o
transition para a regra base, troque qualquer left, top, width ou
margin animado por transform, e feche com o bloco de
prefers-reduced-motion. Depois abra o painel Performance do DevTools e grave
o hover — você deve ver a linha de Layout e a de Paint vazias, como na tabela
deste artigo.
O passo seguinte é o movimento que roda sem gatilho e com etapas intermediárias, em animation e @keyframes. E se quiser ver onde este assunto entra no percurso completo, o guia de CSS mostra a ordem inteira.
Perguntas frequentes
Qual a duração ideal para um hover?
Preciso usar will-change para o transform ficar rápido?
Por que a fonte fica borrada quando eu uso scale?
Dá para animar um gradiente de fundo?
transition e animation fazem a mesma coisa?
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 (Playwright 1.62, macOS), e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — transform — developer.mozilla.org
- MDN — Using CSS transitions — developer.mozilla.org
- MDN — interpolate-size — developer.mozilla.org
- CSS Transforms Module Level 2 — W3C — w3.org


