$cat ~/posts/pixel-dissolve-terminal-rewrite

bilíngue · en / pt

← /blog

pixel dissolve, rewrite de terminal


Esse site tem duas transições que importam pra mim de verdade. Trocar o tema dissolve a página inteira em pixels e de volta. Trocar a língua sobrescreve todo texto visível na tela, da esquerda pra direita, com um cursor, como um terminal se redesenhando. As duas moram no rodapé. Vai testar primeiro, o resto do post vai fazer mais sentido.

a troca de tema

A View Transitions API faz o trabalho pesado: document.startViewTransition(update) tira um snapshot da UI antiga, roda o seu update, tira um snapshot da nova e anima entre os dois. Por padrão essa animação é um crossfade. O truque é que os snapshots são só pseudo-elementos estilizáveis, então em vez de um fade eu passo os dois por filtros SVG de pixelização.

export function startPixelTransition(update: () => void | Promise<void>) {
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  if (reduced || typeof document.startViewTransition !== 'function') {
    void update()
    return
  }
  document.startViewTransition(async () => {
    await update()
  })
}

Cada filtro amostra um ponto por célula de N por N (feFlood + feComposite + feTile), mantém só esses pontos e depois os dilata até preencherem suas células (feMorphology). Quatro tamanhos são definidos: 4, 8, 16 e 24 pixels.

Tem uma pegadinha que moldou o efeito inteiro: filter: url(#...) não interpola. O navegador não consegue animar entre dois filtros SVG, ele só troca. Então os keyframes passam pelos tamanhos em degraus discretos:

@keyframes vt-pixel-out {
  0%   { filter: none;      opacity: 1; }
  18%  { filter: url(#px4);  }
  32%  { filter: url(#px8);  opacity: 1; }
  55%  { filter: url(#px16); opacity: 0; }
  100% { filter: url(#px24); opacity: 0; }
}

O aspecto quadradão não é uma limitação que eu contornei. Os degraus são a estética.

Um detalhe de React: as mudanças de DOM dentro do callback precisam ser síncronas, então as trocas de estado passam por flushSync. E quando a API não existe, ou o usuário prefere movimento reduzido, tudo cai num swap instantâneo.

a troca de língua

Um fade entre línguas parecia errado pra um site com cara de terminal. O que um terminal faz é redesenhar. Então é isso que acontece: antes de navegar, snapshotTexts() grava todo nó de texto visível; depois que a página nova renderiza, rewriteTexts(old) devolve as strings antigas pra tela antes do paint e então sobrescreve cada nó do antigo pro novo, da esquerda pra direita, com um cursor na frente da escrita.

Os nós são escalonados de cima pra baixo pela posição no viewport, então a reescrita varre a tela. Nós fora da tela e nós iguais trocam na hora. Movimento reduzido pula a animação inteira.

A restrição interessante é o pareamento. Nós de texto antigos e novos são casados pela ordem no documento, nada mais esperto que isso. Só funciona se as duas versões de língua renderizam a mesma árvore de componentes, e isso molda como os posts são escritos: um post bilíngue precisa manter as duas versões estruturalmente paralelas. Mesmos títulos, mesmos parágrafos, mesmos blocos de código, na mesma ordem. A animação impõe disciplina na escrita, bem quietinha.

Tem também um truque de timing. O router.push() do App Router resolve antes da página nova estar na tela, então uma pequena ponte no layout raiz resolve uma promise num useLayoutEffect quando o pathname muda de verdade. É isso que permite restaurar o texto antigo antes da página nova pintar. Sem flash.

uma pegadinha de teste

Se você tirar screenshot disso com Chrome headless usando --virtual-time-budget, a view transition nunca termina: o callback de update simplesmente não roda em tempo virtual. Teste transições com timing real.

open source

$ quote ~/posts/why-this-site-exists

pretendo abrir o código disso tudo em breve, tanto o blog em si quanto uma lib separada só com os efeitos de transição de tema e idioma.

por que esse site existe2026-07-10pt

Feito. Os dois efeitos agora moram em nuxyel-transitions, MIT: os filtros de pixel, o rewrite e a folha de estilo que precisa andar em sincronia com eles. A ponte com o router do Next fica atrás de um entry point próprio, então o core não carrega dependência de framework nenhuma.

Não está no npm e nem tenho pressa de colocar, porque publicar pacote é assumir manutenção no calendário dos outros, e um repositório é só o código, que era o que eu queria compartilhar desde o começo. Então clona, copia os dois arquivos, rouba os keyframes, tudo aí em cima já era suficiente pra reconstruir mesmo.

E sinceramente, não esperem mais nada relacionado a frontend de mim tão cedo, pelo menos pelos próximos anos...