bilingual · en / pt
pixel dissolve, terminal rewrite
This site has two transitions I actually care about. Switching the theme dissolves the whole page into pixels and back. Switching the language overwrites every visible text on screen, left to right, with a cursor, like a terminal redrawing itself. Both live in the footer. Go try them first, the rest of this post will make more sense.
the theme switch
The View Transitions API does the heavy lifting: document.startViewTransition(update) snapshots the old UI, runs your update, snapshots the new UI, and animates between them. By default that animation is a crossfade. The trick is that the snapshots are just pseudo-elements you can style, so instead of fading I run them through SVG pixelation filters.
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()
})
}Each filter samples one dot per N by N cell (feFlood + feComposite + feTile), keeps only those dots, then dilates them until they fill their cells (feMorphology). Four sizes are defined: 4, 8, 16 and 24 pixels.
There is a catch that shaped the whole effect: filter: url(#...) does not interpolate. The browser cannot animate between two SVG filters, it just switches. So the keyframes step through the sizes discretely:
@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; }
}The chunkiness is not a limitation I worked around. The steps are the aesthetic.
One React detail: DOM updates inside the callback must be synchronous, so state changes go through flushSync. And when the API is missing, or the user prefers reduced motion, everything falls back to an instant swap.
the language switch
Fading between languages felt wrong for a site that looks like a terminal. What a terminal does is redraw. So that is what happens: before navigating, snapshotTexts() records every visible text node; after the new page renders, rewriteTexts(old) puts the old strings back before paint and then overwrites each node from old to new, left to right, with a ▌ cursor at the write head.
Nodes are staggered top to bottom based on their viewport position, so the rewrite sweeps down the screen. Offscreen and unchanged nodes swap instantly. Reduced motion skips the animation entirely.
The interesting constraint is the pairing. Old and new text nodes are matched by document order, nothing smarter than that. It only works if both language versions render the same component tree, and that shapes how posts get written: a bilingual post must keep its two versions structurally parallel. Same headings, same paragraphs, same code blocks, in the same order. The animation quietly enforces discipline on the writing.
There is also a timing trick. The App Router's router.push() resolves before the new page is on screen, so a small bridge in the root layout resolves a promise from a useLayoutEffect when the pathname actually changes. That is what lets the old text be restored before the new page ever paints. No flash.
a testing gotcha
If you screenshot this with headless Chrome using --virtual-time-budget, the view transition never finishes: the update callback simply does not run under virtual time. Test transitions with real timing.
open source
$ quote ~/posts/why-this-site-existswhy this site exists2026-07-10enI intend to open source all of this soon, both the blog itself and a separate lib with just the theme and language transition effects.
Done. Both effects now live in nuxyel-transitions, MIT: the pixel filters, the rewrite, and the stylesheet that has to stay in sync with them. The Next.js router bridge sits behind its own entry point, so the core carries no framework dependency at all.
It is not on npm and I am in no rush to put it there either, because publishing a package is a promise to maintain it on someone else's schedule, and a repository is just the code, which is what I wanted to share in the first place. So clone it, copy the two files, steal the keyframes, everything above was already enough to rebuild it anyway.
And honestly, do not expect anything else frontend related from me any time soon, for the next few years at least...
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-existspor que esse site existe2026-07-10ptpretendo 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.
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...