Next.js page transitions: the code, and the one I deleted

This article is part of the series "Rebuilding a Website with AI". Previous episode: Building a landing page with AI.
Why animations
A website is not just information, it is an experience. And page transitions are the moment most sites break that experience. A white flash, an abrupt reload, and the reader loses the thread.
I wanted navigating asuos.ch to feel fluid, almost tactile. Like flipping through a magazine rather than clicking on links.
What follows is as much about what worked as about what I threw away. Because the interesting part of this episode is the second one.
The animation I built and then deleted
The original idea: when you leave a page, it does not disappear, it falls. Like a sheet of paper sliding off a table. 3D rotation, vertical translation, and a torn border generated in SVG to mimic the ragged edge of a ripped sheet.
Claude Code implemented it. A component of more than 150 lines, about 170 lines of CSS, keyframes, 3D transforms, cubic easing curves. It worked, and it was spectacular.
It lived a few days. The commit that replaced it is called fix: smooth fade page transitions, header/footer stay static, and it removes 211 lines to add 49.
Three reasons, in the order they hit me:
The header and footer were falling too. The animation applied to the whole page. A menu tumbling in 3D on every click is not fluidity, it is instability. The fix was to scope the transition to the main content only, which at the same time made the falling effect far less readable.
It lasted 400 ms. On a first visit it is charming. On the eighth click of a reading session it is a tax. A navigation animation is a cost paid on every interaction, not once.
It showed up in Core Web Vitals. A 3D rotation on a full-page container repaints a lot. A later commit, perf: optimize Core Web Vitals for mobile, kept up the cleanup by replacing a forced reflow with a double requestAnimationFrame.
What remains today is two opacity transitions:
// components/PageTransition.tsx, exit
el.style.transition = 'opacity 250ms ease-out'
el.style.opacity = '0'
el.addEventListener(
'transitionend',
() => {
window.scrollTo(0, 0)
router.push(target)
},
{ once: true }
)
// Safety net in case transitionend never fires
setTimeout(() => {
window.scrollTo(0, 0)
router.push(target)
}, 300)
Less impressive to describe. Clearly better to use.
The lesson, and it is the one running through this whole series: an AI implements any animation idea in minutes. That makes the cost of production almost zero, and therefore removes the natural filter that used to make you give up on bad ideas. The work has moved. It is no longer in the implementation, it is in the decision to keep or to throw away.
The detail that makes the fade believable
Making a page disappear is easy. Making it come back without flickering is less so. If you set opacity to zero and then to one in the same frame, the browser batches both changes and you see no transition at all.
The workaround is a double requestAnimationFrame:
el.style.transition = 'none'
el.style.opacity = '0'
// Double rAF to let the browser apply opacity:0
// before animating, without forcing a reflow
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transition = 'opacity 350ms ease-in'
el.style.opacity = '1'
})
})
The solution you find everywhere else is to read el.offsetHeight to force the browser to recompute layout. It works, and it costs a synchronous reflow on every navigation. The double requestAnimationFrame gets the same result without that cost.
Another choice worth knowing: interception happens at the document level, in the capture phase, rather than by replacing every link on the site.
document.addEventListener('click', handleClick, true)
The handler ignores clicks with modifier keys, target="_blank", downloads, anchors, mailto: links, external links and links to the current page. This approach has an unexpected benefit I measured five months later: it works with any <a> tag, including the ones I added long after. When I replaced my language switcher with real links, client-side navigation kept working without a single line to change here.
Theme switching with the View Transitions API
When you switch from light to dark, a circle spreads out from the button. And the direction depends on which way you are going. Towards light, the wave starts at the button and radiates outwards, like the sun lighting things up. Towards dark, it starts at the edges and converges on the button, like night falling.
This is not done with a full-screen overlay, but with the native startViewTransition API. The component computes the origin and the radius needed to cover the screen, then passes them to CSS through variables:
// 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))
CSS then only has to animate a clip-path on the view pseudo-class:
@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));
}
}
Math.hypot on the distance to the furthest corner is what guarantees the circle covers the entire screen wherever the button sits. A fixed radius leaves a corner uncovered as soon as the window changes proportions.
Infinite scroll
On listings, articles load progressively. An IntersectionObserver triggers the next batch before the reader reaches the bottom:
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore()
},
{ rootMargin: '200px' }
)
rootMargin: '200px' is the parameter that matters. The sentinel fires 200 pixels before entering the viewport, so the next content is already there when you arrive. Without that margin, you see emptiness, then loading. With it, you see nothing at all, which is exactly the point.
Accessibility is not an end-of-project option
prefers-reduced-motion support is present in all three places, and not as a shorter duration. Animations are disabled, not toned down. For some people, on-screen motion is not a matter of taste but of physical comfort, or even nausea.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
el.style.opacity = '1'
return
}
And on the CSS side, for theme switching:
@media (prefers-reduced-motion: reduce) {
[data-transition='theme']::view-transition-old(root),
[data-transition='theme']::view-transition-new(root) {
animation: none !important;
}
}
Theme switching also checks that the API is available before using it, and switches without animation when it is not. An animation that does not degrade gracefully is not a polished animation, it is a bug waiting for its browser.
What I take away
The idea of the page falling like a sheet of paper came from a creative urge, not from the AI. The implementation, the CSS, the 3D transforms, the torn-border SVG, that was Claude Code, in minutes. The decision to throw it all away was mine, four days later.
That is where the real division of labour sits. AI has made execution nearly free. What stays rare is the judgement to tell what impresses in a demo from what holds up on the eighth click.
Animations that give a site character are the kind of polish you can fit into a single day during a Digital Sprint. From a concrete idea to deployment, without weeks of back and forth.
Next in the series: the full SEO audit of the site, and the surprises it turned up.

Toni Dias
Software engineer and technical partner · AsuOs
Related articles

From AI prototype to sellable product: the industrialisation method
How to turn an AI, no-code or home-made prototype into a solid, sellable product: audit, what you keep, foundations, tests and CI/CD.

Taking Over a No-Code or Vibe-Coded Project: Where to Start
How to properly take a no-code or vibe-coded project into your own hands: the concrete steps, from the honest inventory to the audit, for a product that finally holds up.
Ready to transform your digital business?
Toni Dias supports you in your digital strategy with tailored solutions.