Page Transitions That Read Their Direction From the URL
- Published
- Reading time
- 10 min
- Length
- 2,208 words
- Filed under
- design-engineering, motion, view-transitions, nextjs, react
TL;DR
Every page change on the site now uses one transition, and its direction comes from comparing the old and new paths: deeper goes in, shallower comes out, siblings cross-fade.
Going in, the old page recedes to 98% while the new one rises 14px into place; coming out reverses it; the navbar never moves.
The first version blinked because the recipe I started from fades the old page out completely before the new one starts, which leaves 200 ms where less than 60% of either page is visible.
Post pages render two JSON-LD script tags before the article, so the animation was landing on an unrendered script while the article itself snapped in and out.
Other bugs were an empty gap between the two pages and a minified duration read as 0.44 ms, and long titles could not be morphed between a one-line row and a three-line heading.
One wrapper keyed by the path, in the root layout, gives every page the transition without touching a single link.
Measured frame by frame in headless Chromium, the two pages together never fall below 95% opacity, and search, filters, and pagination never animate.
Every page change on this site now animates, and the direction of the animation comes from the address bar. Opening a post from its archive goes one level in, so the archive recedes slightly while the post rises into place over it. Going back to the archive comes one level out and reverses the motion. Moving between Writings and Dev Notes from the navbar is a plain cross-fade, because neither section sits above the other. None of the links on the site know about any of this; one component in the root layout compares the old path with the new one and decides.
Most of the work went into a version that blinked, and in finding out why. The causes turned out to sit almost entirely in how the browser captures a page for a transition, which is worth writing down, because none of them showed up in the code that defines the animation.
Why a navigation needed motion at all
The site is a set of archives (Writings, Dev Notes, Quick Ships, Poetry) and the posts inside them, and the most common thing anyone does on it is open a post and then go back. Before this change, every one of those navigations replaced the page instantly. That is fast, but it gives no indication of where the new page sits in relation to the old one: a post and a sibling archive arrive in exactly the same way.
React's canary builds include a ViewTransition component, Next.js ships those builds, and it can wrap navigations in the browser's View Transitions API with an experimental flag. Under the hood, the browser takes a snapshot of the old page, applies the change, takes a snapshot of the new page, and animates between the two snapshots while the real page waits underneath. That model is what makes the effect cheap, and it is also where every bug in this note came from.
I started from a published set of recipes for this API. Its directional slide fades the old page out in 150 ms and only then fades the new one in, with a blur, and I paired it with a morph that carried the post title from its archive row into the article heading. It looked right in isolation. On the real site, people saw a blink, and on long titles the morph looked strange.
Depth, worked out from the address
The model I ended up with treats the site as a set of levels. A path that extends the current one, such as /writings to /writings/postmortem, is deeper. A path that the current one extends is shallower. Anything else is compared by the number of segments, and two paths at the same depth are siblings.
Each direction has its own motion, and every value lives in one module that the site, this note, and the design page all import. Going in, the old page scales to 98% about its top edge and fades over 200 ms while the new page rises 14 px and fades in over 380 ms, starting 40 ms in. Coming out, the old page drops 14 px and fades while the new page settles from 98% to full size. Across, the old page holds for 60 ms and fades over 180 ms while the new one fades in over 220 ms, with no movement at all. The specimen below runs those exact keyframes on a miniature of the site, and slow motion stretches them by five.
Two things stay out of the transition entirely. The navbar is the same on both pages, so it is pulled out of the snapshot and drawn still on top. Search, filters, pagination, and # anchors change the query string or the hash but not the path, so they never trigger anything.
How every page gets it without touching a link
One wrapper, keyed by the path
React only animates a ViewTransition when it mounts or unmounts inside a transition, and layouts in Next.js persist across navigations, so a wrapper in the layout would normally never fire. Keying it by the current path changes that: every navigation to a new path unmounts the old wrapper and mounts a new one, which is exactly an exit and an enter.
default="none" matters more than it looks. Without it, every transition on the page, including ones started by unrelated state updates, would cross-fade. With it, the wrapper animates on exactly two occasions: when a page leaves and when one arrives.
Deciding the direction before the commit
prepareNavigation runs while the new page renders, compares the committed path with the new one, and stores the direction. onExit and onEnter then read it and animate the old and new snapshots with the Web Animations API. The stored direction is only replaced by the next navigation; I explain why in the section on what went wrong.
Every animation ends where the stylesheet rests
The stylesheet gives each snapshot a resting state, which is also the final frame of every animation: the outgoing page hidden, the incoming page in place. The animations use fill: "backwards", so once they finish they stop applying anything, and the snapshot shows the stylesheet's state, which already matches the last frame.
This also covers every navigation that has no direction. If something reaches the wrapper without one, the outgoing page is simply hidden and the incoming page is in place, which is an instant swap rather than a broken frame.
What the numbers show
The chart below is computed from the real keyframes and timing curves rather than drawn by hand. It plots the opacity of the old page, the new page, and the two together over the first 480 ms of a navigation. On a site with a black background, any moment where the two together fall well below full reads as a dark frame.
The recipe on the left takes the combined opacity to zero at 150 ms, and for 200 ms less than 60% of either page is visible. That is the blink people saw. The depth transition on the right overlaps the two pages, so the combined opacity never falls below 93% in the computed curves.
I also measured the live site in headless Chromium at 1440 by 900, recording the computed opacity and transform of every snapshot on every animation frame of a real navigation.
| Navigation | Direction | Lowest combined opacity | Transition length |
|---|---|---|---|
| Home to Writings | in | 95% | 503 ms |
| Writings to a post | in | 96% | 474 ms |
| Post back to Writings | out | 96% | 515 ms |
| Writings to Dev Notes | across | no dip below 100% | 334 ms |
| Typing in the archive search | none | not animated | no transition |
The live numbers sit slightly above the computed ones because frames are sampled every 16 ms or so and rarely land on the exact lowest point.
The post that kept blinking
The bug that took longest only appeared on posts. I could open "What Went Wrong With ibbe" from the archive, and the archive would recede correctly, but the post simply appeared at full opacity instead of rising. Going back, the post disappeared on the first frame instead of dropping away.
My first suspicion was timing. I had been clearing the stored direction in an effect after the commit, and React calls onExit and onEnter once the transition is ready, which can be after those effects. Keeping the direction until the next navigation is the safer rule, so I changed it, but the post behaved exactly as before. Logging every animate() call then showed that both animations were being created with the right keyframes. The incoming animation targeted a snapshot named _t_1_, and reading that snapshot's computed style returned an opacity of 1 and no transform on every frame, as though the animation did nothing.
The reason was in the markup. A post page renders two <script type="application/ld+json"> tags for structured data before the <article>. React gives each top-level node inside a ViewTransition its own snapshot name, so _t_1_ belonged to the first script tag, which is never rendered, and the article was captured under a different name with no animation at all. Wrapping each page in a single <div> gives React exactly one node to name, and the post started rising like every other page. No stylesheet depended on pages being direct children of <main>, so the wrapper changed nothing about the layout.
What went wrong, and what prevents it now
The recipe left the screen empty. Exiting fully before entering is a reasonable choice on a light page with a lot of chrome, but on this site the gap is a black frame. The depth transition starts the new page 40 ms after the old one begins to leave, and the across fade holds the old page for 60 ms while the new one is already coming in.
A minified duration was read as 0.44 ms. The title morph read its duration from a CSS custom property, --vt-title-duration: 440ms. The build minified it to .44s, and parseFloat returned 0.44, which the Web Animations API treats as milliseconds. Every title animation finished instantly, and what people saw was the browser's default movement of the full-size heading. The timings now live in a TypeScript module and are never read back from CSS.
Finished animations could leak into the next transition. With fill: "both", an animation keeps applying its last frame after it ends, and because a snapshot is addressed by name, a later transition that reuses the name can pick that frame up, for example a title held at opacity 0 when returning to the same post. Ending every track on the stylesheet's resting state and using fill: "backwards" means a finished animation applies nothing.
Long titles could not line up. A title on one line in the archive and on three lines in the article cannot be matched glyph for glyph, and every attempt to morph between them showed one of the two layouts breaking apart mid-flight. I removed the title morph rather than keep tuning it. The depth motion makes the relationship between the two pages clear without asking the type to do something it cannot.
The browser refused to run transitions while I tested. For a while every transition I triggered in the browser pane I was testing in failed with "Transition was aborted because of invalid state". The pane was hidden, and browsers do not run view transitions in a hidden document. The site was fine; I moved the measurements to headless Chromium, where the page counts as visible.
Snapshots are the thing being animated
The general lesson is that a view transition animates pictures of the page, not the page, so the questions worth asking are about the pictures. Which elements get their own snapshot, what each snapshot shows when no animation is applying to it, and what state the real page is in when the snapshots are released. Every bug above was an answer to one of those questions that I had assumed rather than checked.
A few practices came out of it. Give the transition exactly one element to capture per page. Make the stylesheet's resting state identical to the final frame, so the end of an animation is never a visible event. Keep timings in code rather than reading them back from CSS that a build may rewrite. Measure the combined opacity of the two pages, because a transition can look correct frame by frame and still leave the screen empty between them.
Adding it to a Next.js site
The pieces are small. Turn on experimental.viewTransition in next.config.ts, render one wrapper around {children} in the root layout keyed by usePathname(), give it default="none", and animate instance.old and instance.new in onExit and onEnter. Wrap the page content in a single element, pull persistent chrome such as the navbar out with its own view-transition-name, set resting states in the stylesheet, and return a static swap under prefers-reduced-motion.
The browser's own back and forward buttons do not animate yet. They fire a synchronous popstate, which the View Transitions API cannot wrap, so returning that way swaps the page instantly. The back link at the top of every post navigates to its archive instead, which does animate. If browsers start allowing transitions on history navigation, the same wrapper will pick it up without any change to the rest of the site.