The View Transitions API: Building Page-to-Page Motion in Multi-Page Web Apps

Have you ever clicked a link on your own site and watched the screen blink white before the next page painted? If you run anything multi-page — a documentation set, a marketing surface, a server-rendered game portal — you have been shipping that blink to every visitor since launch.
For most of the last decade the only fix was to stop navigating. You adopted a client-side router, intercepted link clicks, and bought app-like motion with a JavaScript bundle, a hydration pass, and a scroll-restoration bug that never quite died.
Cross-document view transitions remove that trade. The browser animates between two separate HTML documents, and the entire opt-in is a few lines of CSS.
Cross-document view transitions animate between two separate HTML documents with CSS alone. Both pages must be same-origin and both must opt in with the @view-transition at-rule. No client-side router is required.
What A Cross-Document Transition Actually Does
The mechanism is simpler than the API surface suggests. When a same-origin navigation is ready to commit, the browser captures the outgoing page as a set of images, loads and captures the incoming page the same way, and animates between the two sets.
Those captures are not live DOM. They are flattened textures composited on the GPU, which is why the default cross-fade holds a steady frame rate even while the incoming document is still doing layout and script work.
That distinction matters more than it sounds. Because the animation runs on the compositor, it survives main-thread congestion the same way a plain CSS transform does — the same property that makes procedural browser animation cheap enough to run every frame.
The CSS That Turns It On
Two things have to be true before anything animates. Both documents must be same-origin, and both must opt in with the @view-transition at-rule carrying a navigation descriptor of auto.
Put that rule in a stylesheet both pages load. Miss it on the outgoing page and there is no capture to animate from; miss it on the incoming page and the browser throws away the one it already took.
Add the @view-transition at-rule with a navigation descriptor of auto to both the page you leave and the page you land on. Miss either side and the browser discards the snapshot and does a hard cut.
That is the whole baseline, and it is worth shipping on its own before you name a single element. With nothing else configured you get a 250ms cross-fade of the full viewport, which already reads as far less abrupt than a white flash.
Keep in mind that the default animates exactly one thing. Everything visible is captured into a single image pair named root, and that pair fades.
The Pseudo-Element Tree You Are Actually Animating
Every captured element produces a small tree of pseudo-elements, and you style that tree rather than the element itself. Knowing the four levels is what separates a transition you can debug from one you can only re-roll until it looks right.
| Pseudo-element | What it is | What you set on it |
|---|---|---|
::view-transition | Root overlay covering the snapshot containing block | Backdrop, stacking, global overrides |
::view-transition-group | Animates size and position from old geometry to new | Duration, easing, custom animation |
::view-transition-image-pair | Isolation container holding both captures | Blend mode and isolation behavior |
::view-transition-old | Static image of the outgoing element | Exit animation, object-fit |
::view-transition-new | Live representation of the incoming element | Entry animation, object-fit |
The group is where most of the useful work happens. It carries the automatic morph between the old rectangle and the new one, so changing its duration and easing changes how the shape travel itself feels.
The old and new pseudo-elements are where you control fade and slide. Note that they stack inside the image pair, so animating opacity down on one and up on the other without overlap produces a visible dip to the backdrop halfway through.
Naming Elements Without Breaking The Transition
To morph a specific element across a navigation, give it a view-transition-name with the same identifier on both pages. A thumbnail in a grid and the hero image on the detail page sharing one name is the canonical example, and it is the effect that sells the feature.
The constraint that trips people is uniqueness. A name has to appear exactly once per document at capture time, and the browser does not silently pick a winner.
A view-transition-name must be unique within its document at capture time. Two elements sharing one name abort the whole transition, so the navigation lands as an instant cut with no animation at all.
Two escape hatches exist for the common cases. view-transition-class lets many differently-named elements share one block of animation rules, and view-transition-name: match-element asks the browser to generate a stable unique name per element.
Be aware that match-element solves a same-document problem, not a cross-document one. It cannot pair an element in one document with an element in another, so shared-element morphs across a real navigation still need identifiers you author.
Named elements also have to be capturable in the first place. An inline element that fragments across two lines does not produce a single box, and a name on it will drop the transition rather than animate half of it.
Telling Forward From Back
A cross-fade that looks identical going forward and going back is the tell of a default install. Direction is what makes navigation motion read as navigation rather than as decoration.
The CSS half is the types descriptor on the at-rule paired with the :active-view-transition-type pseudo-class. You declare a type such as slide-forward, then scope your group and old and new animations to that type.
The dynamic half needs two events. pageswap fires on the outgoing document immediately before it is replaced, and pagereveal fires on the incoming document before its first render.
Both hand you the active transition object along with the navigation activation, which carries the navigation type — push, replace, reload, or traverse. Read it, set the type accordingly, and a back navigation animates backward instead of pretending it is a forward step.
That is also where you cancel. Skipping the transition inside pagereveal drops the animation and lands the navigation instantly, which is the right call for error pages, auth bounces, and redirect landings.
What Happens On A Slow Network
Here is the failure mode the demos never show. The browser does not capture the outgoing page the instant you click — it waits until the navigation is ready to commit, so a slow response gives the user nothing at all.
No motion, no progress, just a link that appears not to have worked. Once the commit does happen, the old page becomes a still image until the new document can paint.
Once the navigation commits, the outgoing page is held as a still image until the new document can paint. On a slow connection that reads as a dead window with no progress feedback at all.
Chromium caps how long it will hold that state. Blow past the cap and the transition is discarded, the navigation completes as a plain cut, and your carefully authored morph simply never runs for the users on the worst connections.
This is the same perception problem you solve in a game client with a streaming budget rather than a spinner, which is the argument running through asset streaming for browser games. Users forgive latency they can watch progressing and punish latency that looks like a broken tap.
The mitigations are boring and effective. Keep the incoming document's critical path small, ship the shared-element CSS in the head rather than in a late stylesheet, and use speculation rules to prefetch or prerender the documents a user is most likely to open next.
Render-Blocking The Incoming Page
A shared-element morph needs its target to exist before first render. If the hero sits forty kilobytes down the document, first paint can happen before that element parses, and the browser animates toward something that was not there yet.
The fix is an explicit render expectation. A link element with rel="expect", an href pointing at the target element's id, and blocking="render" tells the browser to hold first paint until that element has been parsed.
Use it narrowly. Every id you block on is latency you added by hand, so block on the one element the transition depends on and let the rest of the document stream in behind it.
The Fallback Path
The good news here is structural rather than something you have to engineer. A browser without support treats @view-transition as an unknown at-rule, drops it, and navigates the way it always has.
Browsers without support ignore the @view-transition rule and navigate the way they always have. That makes this pure progressive enhancement, because the fallback is exactly the hard cut you ship today.
You can still gate styles explicitly when a partial implementation would look worse than none. A feature query testing view-transition-name: none is the standard detection, and it lets you keep a separate layout for browsers that will never run the animation.
Reduced motion is the part teams skip, and it is not optional. The user agent will happily run your full-viewport sweep for a reader who has asked the operating system to stop moving things.
The user agent does not disable view transitions for prefers-reduced-motion. You have to shorten or cancel the animations inside that media query yourself, or motion-sensitive users get the full sweep.
In practice that means a prefers-reduced-motion block that collapses animation duration on the group, old, and new pseudo-elements. A near-zero duration keeps the cross-fade legible as a state change without the travel, which is usually better than removing the names entirely.
Failure Modes Worth Testing Before You Ship
Most view transition bugs stop being mysterious once you have seen the list. These are the ones that surface in real navigation traffic rather than in a tidy two-page demo:
- Duplicate names. A grid that renders the same product twice, or a template that names both a mobile and a desktop header, kills the transition outright. Audit names per rendered document, not per component.
- Fixed headers. An unnamed fixed header is captured inside the root image, so it fades and drifts with the page body. Give it its own name and it holds still while the content moves under it.
- Off-screen shared elements. The snapshot containing block is roughly the viewport, so a thumbnail three thousand pixels down the grid and a hero at the top of the detail page produce a group animation that streaks across the screen. Set the name only when the element is actually in view.
- Oversized captures. Naming a long article body creates a texture as tall as the element, and very large captures run into GPU texture limits. Name the container you want to morph, not the scroll region it lives in.
- Scrollbar shift. A page that scrolls and a page that does not differ in content width, and the cross-fade makes that difference visible as a horizontal jitter. A stable scrollbar gutter removes it.
- Gesture navigation. Back-swipe on mobile traverses history and triggers a transition mid-gesture, which can fight your own touch handling. Test it on a real device alongside your other mobile touch controls.
- Navigations that do not qualify. Cross-origin hops, and flows that bounce off another origin mid-redirect, do not transition. A checkout that routes through a payment host will cut, and no amount of CSS changes that.
All of these share a diagnosis path. Disable your custom animations, confirm the default root cross-fade still runs, and then add names back one at a time until the transition breaks.
Router Or No Router
The honest comparison is not that one approach wins. It is that the two fail differently, and the failure mode is usually what you should be choosing on.
| Concern | Client-side router | Cross-document transitions |
|---|---|---|
| JavaScript required | Router, hydration, route bundles | None for the baseline |
| Back and forward | Your code owns history state | Browser owns it, bfcache included |
| Scroll restoration | Manual, and a recurring bug source | Native |
| Motion control | Total, at the cost of building it | Pseudo-element tree only |
| State between pages | Held in memory | Must be persisted explicitly |
| Failure mode | Blank route or stuck spinner | Hard cut, page still works |
That state row is the real limitation. In-memory state dies at the document boundary, so a cart, a half-completed form, or a session-scoped play state has to live somewhere durable — which is the case for IndexedDB in browser games rather than a global object.
When To Reach For Something Else
Cross-document transitions are a navigation tool, not a general animation system. If your motion is continuous — a simulation tick, a physics step, a scene that has to keep running — a document swap tears it down and rebuilds it.
Anything driven by fixed timestep loops belongs inside one document, with routing handled in your own state machine. The moment the loop has to survive the navigation, you are back to a client-side router and that is the correct answer.
The same applies to heavy rendering surfaces. A scene worth the decision in Canvas versus WebGL pays a real context-creation cost on every document load, and work you have already moved into OffscreenCanvas workers gets terminated along with the page.
For content surfaces, though, the calculus flips completely. Docs, listings, profiles, catalogs, and marketing pages have no continuous state to protect, which is exactly the shape this API was built for.
Where To Start
Ship the at-rule on both templates first and change nothing else. A default cross-fade costs you nothing, degrades to your current behavior everywhere it is unsupported, and gives you a baseline to measure the rest against.
Then add exactly one shared element — the thumbnail-to-hero morph is the highest-value first move — and test it on a throttled connection before you test it on your laptop. The transition that looks best on fast hardware is usually the one that fails hardest on a phone.


