Transições de página em Next.js: o código, e aquela que apaguei

Este artigo faz parte da série "Redesign de site com IA". Episódio anterior: Criar uma landing page com IA.
Porquê animações
Um site não é apenas informação, é uma experiência. E as transições entre páginas são o momento em que a maioria dos sites parte essa experiência. Um flash branco, um recarregamento brusco, e o leitor perde o fio.
Queria que navegar no asuos.ch fosse fluido, quase táctil. Como folhear uma revista em vez de clicar em links.
O que se segue é tanto sobre o que resultou como sobre o que deitei fora. Porque a parte interessante deste episódio é a segunda.
A animação que construí e depois apaguei
A ideia de partida: quando se sai de uma página, ela não desaparece, cai. Como uma folha de papel a deslizar da mesa. Rotação 3D, translação vertical, e um bordo rasgado gerado em SVG para simular a margem irregular de uma folha arrancada.
O Claude Code implementou-a. Um componente com mais de 150 linhas, cerca de 170 linhas de CSS, keyframes, transforms 3D, curvas de aceleração cúbicas. Funcionava, e era espetacular.
Viveu alguns dias. O commit que a substituiu chama-se fix: smooth fade page transitions, header/footer stay static, e retira 211 linhas para acrescentar 49.
Três razões, pela ordem em que me apareceram:
O cabeçalho e o rodapé também caíam. A animação aplicava-se à página inteira. Ora um menu que baloiça em 3D a cada clique não é fluidez, é instabilidade. A correção consistiu em limitar a transição ao conteúdo principal, o que tornava ao mesmo tempo o efeito de queda muito menos legível.
Durava 400 ms. Na primeira visita é encantador. Ao oitavo clique de uma sessão de leitura é um imposto. Uma animação de navegação é um custo pago em cada interação, não uma vez.
Via-se nos Core Web Vitals. Uma rotação 3D num contentor de página inteira repinta muito. Um commit posterior, perf: optimize Core Web Vitals for mobile, continuou a limpeza substituindo um reflow forçado por um duplo requestAnimationFrame.
O que resta hoje cabe em duas transições de opacidade:
// components/PageTransition.tsx, saída
el.style.transition = 'opacity 250ms ease-out'
el.style.opacity = '0'
el.addEventListener(
'transitionend',
() => {
window.scrollTo(0, 0)
router.push(target)
},
{ once: true }
)
// Rede de segurança se o transitionend nunca disparar
setTimeout(() => {
window.scrollTo(0, 0)
router.push(target)
}, 300)
Menos impressionante de contar. Nitidamente melhor de usar.
A lição, e é a de toda a série: uma IA implementa qualquer ideia de animação em minutos. Isso torna o custo de produção quase nulo, e portanto elimina o filtro natural que antes o fazia desistir das más ideias. O trabalho deslocou-se. Já não está na implementação, está na decisão de guardar ou de deitar fora.
O detalhe que torna o fundido credível
Fazer uma página desaparecer é fácil. Fazê-la reaparecer sem piscar é menos. Se puser a opacidade a zero e depois a um no mesmo ciclo, o browser agrupa as duas alterações e não se vê transição nenhuma.
A solução cabe num duplo requestAnimationFrame:
el.style.transition = 'none'
el.style.opacity = '0'
// Duplo rAF para deixar o browser aplicar opacity:0
// antes de animar, sem provocar um reflow forçado
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transition = 'opacity 350ms ease-in'
el.style.opacity = '1'
})
})
A solução que se encontra em todo o lado consiste em ler el.offsetHeight para forçar o browser a recalcular o layout. Funciona, e custa um reflow síncrono a cada navegação. O duplo requestAnimationFrame obtém o mesmo resultado sem esse custo.
Outra escolha a conhecer: a interceção faz-se ao nível do documento, na fase de captura, e não substituindo cada link do site.
document.addEventListener('click', handleClick, true)
O handler ignora os cliques com tecla modificadora, os target="_blank", os downloads, as âncoras, os mailto:, os links externos e os links para a página atual. Esta abordagem tem uma vantagem inesperada que medi cinco meses depois: funciona com qualquer tag <a>, incluindo as que acrescentei muito mais tarde. Quando substituí o meu seletor de língua por links a sério, a navegação client continuou a funcionar sem uma linha a mudar aqui.
A mudança de tema com a View Transitions API
Quando se passa do tema claro ao tema escuro, um círculo propaga-se a partir do botão. E a direção depende do sentido. Para o claro, a onda parte do botão e irradia para fora, como o sol que ilumina. Para o escuro, parte das margens e converge para o botão, como a noite que cai.
Não é feito com uma camada de ecrã inteiro, mas com a API nativa startViewTransition. O componente calcula a origem e o raio necessário para cobrir o ecrã, e depois passa-os ao CSS através de variáveis:
// components/ThemeSwitch.tsx
const x = e.clientX
const y = e.clientY
const maxRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
)
document.documentElement.dataset.transition = 'theme'
document.documentElement.style.setProperty('--theme-switch-x', `${x}px`)
document.documentElement.style.setProperty('--theme-switch-y', `${y}px`)
document.documentElement.style.setProperty('--theme-switch-radius', `${maxRadius}px`)
const transition = document.startViewTransition(() => setTheme(nextTheme))
Ao CSS resta apenas animar um clip-path na pseudo-classe de vista:
@keyframes theme-reveal-out {
from {
clip-path: circle(var(--theme-switch-radius) at var(--theme-switch-x) var(--theme-switch-y));
}
to {
clip-path: circle(0px at var(--theme-switch-x) var(--theme-switch-y));
}
}
O Math.hypot sobre a distância ao canto mais afastado é o que garante que o círculo cobre mesmo o ecrã inteiro, esteja o botão onde estiver. Um raio fixo deixa um canto por cobrir assim que a janela muda de proporções.
O scroll infinito
Nas listagens, os artigos carregam progressivamente. Um IntersectionObserver desencadeia o lote seguinte antes de o leitor chegar ao fundo:
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore()
},
{ rootMargin: '200px' }
)
O rootMargin: '200px' é o parâmetro que conta. A sentinela dispara 200 píxeis antes de entrar no campo de visão, portanto o conteúdo seguinte já lá está quando se chega. Sem essa margem, vê-se o vazio, depois o carregamento. Com ela, não se vê nada, que é exatamente o objetivo.
A acessibilidade não é uma opção de fim de obra
O suporte de prefers-reduced-motion está presente nos três sítios, e não como uma redução de duração. As animações são desativadas, não atenuadas. Para algumas pessoas, o movimento no ecrã não é uma questão de gosto mas de conforto físico, ou mesmo de náusea.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
el.style.opacity = '1'
return
}
E do lado do CSS, para a mudança de tema:
@media (prefers-reduced-motion: reduce) {
[data-transition='theme']::view-transition-old(root),
[data-transition='theme']::view-transition-new(root) {
animation: none !important;
}
}
A mudança de tema verifica também a disponibilidade da API antes de a usar, e muda sem animação quando ela falta. Uma animação que não degrada com elegância não é uma animação cuidada, é um bug à espera do seu browser.
O que fica
A ideia da página que cai como uma folha de papel veio de uma vontade criativa, não da IA. A implementação, o CSS, os transforms 3D, o SVG de bordo rasgado, foi o Claude Code, em minutos. A decisão de deitar tudo fora foi minha, quatro dias depois.
É aí que está a verdadeira repartição de papéis. A IA tornou a execução quase gratuita. O que continua raro é o discernimento para distinguir o que impressiona numa demo daquilo que aguenta ao oitavo clique.
Estas animações que dão carácter a um site são o género de acabamento que se integra num dia durante um Sprint Digital. Da ideia concreta à entrega, sem semanas de idas e vindas.
Episódio seguinte da série: a auditoria SEO completa do site, e as surpresas que revelou.

Toni Dias
Engenheiro de software e parceiro técnico · AsuOs
Artigos relacionados

Retomar um projeto no-code ou vibe coded: por onde começar
Como pegar corretamente num projeto no-code ou vibe coded: os passos concretos, do balanço honesto até à auditoria, para um produto que finalmente se aguenta.

Juntei-me aos Mugiwara: a minha tripulação de agentes IA
Há algumas semanas que tenho a Nami, o Sanji, o Zoro, o Chopper, o Usopp, a Robin, o Franky, o Jimbei e o Brook na minha equipa. Bem-vindo à orquestração de agentes IA, onde cada membro da tripulação tem o seu papel, a sua especialidade e a sua biblioteca. O que isto muda não é apenas a minha produtividade. É toda a minha forma de construir um produto.
Pronto para transformar o seu negócio digital?
Toni Dias apoia-o na sua estratégia digital com soluções à medida.