On a new project where I am in charge of both UX/UI and front-end development, clicking a piece of content brings its page in from the right, and going back to the list brings it back from the left. You know you went further in, or came back, without reading anything.

A well-chosen transition informs: it says “something else”, “further in” or “back” without a word. The View Transition API makes that movement almost free to write, and everything happens in what the browser photographs, and when. The non-negotiable advantage of minimal motion design in UX.

Two snapshots and the right moment

The principle is simple. The browser photographs the page, calls a callback that changes the DOM, photographs the result, then animates from one image to the other. Until the callback returns, the screen stays frozen on the first.

On the first try, the fade started about 900 ms after the click, and on the wrong page. Astro’s router replaces the page body inside the callback, then hands its props back to the React island, and React renders later: when the browser took its second photo, the new view did not exist yet. The fade went from the old page to the old page, and the new one appeared afterwards, all at once. Seen from the mouse, it simply looked like a slow site.

Duration and curve had nothing to do with it, it was a matter of timing. The new view is now rendered synchronously, with flushSync, in astro:after-swap, the last synchronous event of the callback. It costs 51 ms, and the second photo is the right one.

Everything rests on the second snapshot: once it is right, the curve only has to do its job.

810 ms to photograph a canvas

The home page carries a WebGPU backdrop. With it, a capture went from 4 ms to 810 ms: to photograph an accelerated canvas, the browser reads back from the GPU, and that readback costs more than the whole rest of the page.

So the canvas is hidden for the duration of the trip, with visibility: hidden rather than display: none, which would lay the page out again just before photographing it. What freezes the screen before a transition is not the animation. It is the capture.

What stays in place has a name

The top bar, for its part, flickered. It was drawn differently on the two pages, and for 0.22 s the fade stacked two nearly identical bars, on the one element of the screen that had no reason to move.

There is now a single bar, shared by both pages, and it carries a view-transition-name. The browser lifts it out of the general capture and pairs the one before with the one after. With no animation, the pairing is enough: it stays still while the page changes underneath.

.top-bar {
  view-transition-name: top-bar;
}

::view-transition-group(top-bar),
::view-transition-old(top-bar),
::view-transition-new(top-bar) {
  animation: none;
  mix-blend-mode: normal;
}

One jump was left. One page had a scrollbar, the other did not: the usable width went from 1426 to 1441 px, and the right corner of the bar jumped by 15 px. scrollbar-gutter: stable reserves the gutter everywhere, scrollbar or not.

After these three fixes, the fade started at 178 ms and ended at 500 ms.

From click to fade, on the first try and after the three fixes Two horizontal timelines start from the same point, marked click. On the first, titled first try, a dotted line runs from the click to a vertical marker placed at 900 ms, captioned the fade starts, on the wrong page. On the second, titled after the three fixes, the dotted line stops at 178 ms, where a coloured band marked the fade begins, ending at 500 ms, well before the position of the marker on the first timeline. FIRST TRY click 900 ms the fade starts, on the wrong page AFTER THE THREE FIXES the fade click 178 ms 500 ms
The same click: the whole fade now fits before the moment the old one started.

Further in, or back

Once each piece of content had its own page, the site gained depth: home at 0, the list at 1, the content at 2. The 0.22 s fade said “something else”. A 0.36 s sideways slide says “further in” or “back”, on a cubic-bezier(0.22, 1, 0.36, 1) curve that starts fast and settles.

The deeper page goes on top and crosses the full width. The other one only shifts by a third and darkens. Without that gap, two pages on the same background look like a scrolling banner: you see text moving sideways without understanding that one page is covering another.

The slide towards a deeper page, caught halfway A window with a dotted outline, topped by a solid bar captioned the named bar stays in place. Two pages of the same size cross it. Underneath, the page marked list · 1 is shifted to the left, spills out of the window on that side, and its fill is darker. On top, outlined in the accent colour, the page marked content · 2 comes in from the right and already covers more than half of the window. Below the drawing, a long arrow pointing left, captioned the full width, runs from the right edge of the window to its left edge. Further down, a short arrow pointing left, captioned a third, darkened, starts from the left edge of the window. FURTHER IN: FROM LIST TO CONTENT the named bar stays in place list · 1 content · 2 the full width a third, darkened
Two unequal runs, and their gap is what tells which page is arriving.
:root { --side: 1; }
:root[dir="rtl"] { --side: -1; }

@keyframes enter-far {
  from { translate: calc(100% * var(--side)) 0; }
}
@keyframes leave-near {
  to { translate: calc(-30% * var(--side)) 0; filter: brightness(0.7); }
}

:root[data-direction]::view-transition-old(root),
:root[data-direction]::view-transition-new(root) {
  animation-duration: 0.36s;
  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
  animation-fill-mode: both;
  mix-blend-mode: normal;
}
:root[data-direction="forward"]::view-transition-old(root) { animation-name: leave-near; }
:root[data-direction="forward"]::view-transition-new(root) { animation-name: enter-far; }

Going back swaps the roles: the page that leaves crosses the width and goes on top with a z-index: 1, the one arriving comes back from its third. The mix-blend-mode: normal is there for a reason: during a fade, the browser adds the two images together, and two pages that overlap should cover each other, not lighten each other. On the production build, the slide starts 34 ms after the click and ends at 412 ms.

That leaves direction. The router provides one, “forward” or “back”, but every clicked link counts as “forward”. That includes the “← Back to the list” link on a piece of content, which would have brought the list in from the right, as if you were going deeper when you are climbing back up. So direction is derived from the depth of the two pages: deeper, the page enters from the right; shallower, it comes back from the left. History only breaks the tie between two pages of the same depth.

The router also replaces the attributes of <html> during the swap: a data-direction set before disappears, it has to be set right after. And the ::view-transition-* pseudo-elements descend from the root: a variable set on a class of <body> never reaches them. --side lives on :root, and it is what flips the whole movement in right-to-left writing.

When I do not animate

Internal tool screens keep the fade. A screen that crosses the window on every tab gets noticed the first time and wears you out by the tenth. Two identical addresses do not slide either: nothing has changed depth, nothing should suggest a trip.

On mobile, the native back gesture has its own animation. The router then skips its own so as not to play two: the thumb has already said “back”.

Whoever asks for less motion gets none. The usual rule, * { animation-duration: 0.01ms } under prefers-reduced-motion, does not reach the transition pseudo-elements, which * does not select. They have to be targeted by name.

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

None of this needs an animation library. Simple motion design is choosing what moves, what stays, and in which direction.