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

bilingual · en / pt

← /blog

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-exists

I intend to open source all of this soon, both the blog itself and a separate lib with just the theme and language transition effects.

why this site exists2026-07-10en

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...