Most View Transitions tutorials show you the SPA version: wrap a state update in document.startViewTransition() and enjoy a smooth cross-fade. That API shipped back in Chrome 111, and it’s old news. The genuinely transformative piece arrived later — cross-document view transitions, which animate full page navigations in multi-page apps with almost no JavaScript at all. And as of late 2025, the support story finally crossed the threshold where shipping them on real sites is reasonable.
Same-document transitions are now Baseline, newly available: Chrome and Edge from version 111, Safari from 18, and Firefox — the last holdout — shipped Level 1 in Firefox 144. Cross-document transitions are still Chromium-and-Safari only (Firefox has it under development), but with graceful degradation that’s a progressive enhancement you can ship today. This post covers how both mechanisms work, where the sharp edges are, and how to structure the CSS so unsupported browsers simply get a normal page load.
The mental model: snapshots, a pseudo-element tree, CSS animations
Both flavors of the API work the same way under the hood. When a transition runs, the browser captures a screenshot of the old state and a live representation of the new state, then builds a tree of pseudo-elements:
::view-transition— the root container overlaid on the whole page::view-transition-group(name)— one per animated element, morphs position/size::view-transition-image-pair(name)— holds the old and new snapshots::view-transition-old(name)and::view-transition-new(name)— the outgoing and incoming captures
By default you get a 250ms cross-fade of the entire page root. All customization happens in CSS, targeting those pseudo-elements. The names come from the view-transition-name CSS property: give an element a name in both the old and the new page, and the browser animates it as a unit — morphing position and size between where it was and where it ends up. That’s how a product-card thumbnail can appear to expand into the product-page hero image, even across a full page load.
Same-document transitions for SPAs
In a single-page app, you trigger the transition explicitly by wrapping your DOM update in a callback:
function updatePage(newState) {
const applyUpdate = () => {
// mutate the DOM as you normally would
renderList(newState);
};
if (!document.startViewTransition) {
applyUpdate();
return;
}
const transition = document.startViewTransition(applyUpdate);
transition.finished.finally(() => console.log("done"));
}
The browser snapshots the current state, calls your callback, snapshots the result, and animates between them. The callback is invoked after the old snapshot is taken but before the new capture — so all DOM mutation happens inside it.
One underused addition is view transition types, which let one transition behave differently depending on direction. You pass a type when starting, and the :active-view-transition() pseudo-class exposes it to CSS:
document.startViewTransition(
{ update: applyUpdate, types: ["slide-forward"] },
);
:root {
view-transition-name: none;
}
&:active-view-transition(slide-forward) {
&::view-transition-old(root) {
animation: slide-out-to-left 0.3s ease both;
}
&::view-transition-new(root) {
animation: slide-in-from-right 0.3s ease both;
}
}
&:active-view-transition(slide-back) {
&::view-transition-old(root) {
animation: slide-out-to-right 0.3s ease both;
}
&::view-transition-new(root) {
animation: slide-in-from-left 0.3s ease both;
}
}
Firefox 144 shipped the same-document core plus the styling primitives (view-transition-name, the pseudo-elements, :active-view-transition), so this pattern now works across all three engines. Chrome additionally ships newer Level 2 extensions — types themselves landed in Firefox only in 147, so treat them as enhancement-on-enhancement and gate accordingly.
Cross-document transitions for MPAs: the two-line version
For a traditional multi-page site, the trigger isn’t JavaScript at all — it’s CSS. Both pages opt in:
@view-transition {
navigation: auto;
}
That’s it. Same-origin navigations between the two pages now run a default cross-fade. There is no API call; the browser intercepts the navigation, snapshots the old page, waits for the new one to render, and animates. If either page lacks the rule, or the navigation is cross-origin, it degrades to a normal load.
To animate a shared element — the thumbnail-to-hero morph — assign matching names on both pages:
/* listing page */
.product-card img {
view-transition-name: product-hero;
}
/* detail page */
.product-detail img {
view-transition-name: product-hero;
}
Names must be unique per page, which is the natural constraint — only one element can be “the product hero” at a time. Dynamic sites generate these per item; just ensure the same item’s name matches on both sides.
Controlling the handoff: pageswap and pagereveal
Two events let you participate in the handoff on the JavaScript side. pageswap fires on the outgoing page just before the old snapshot is taken; pagereveal fires on the incoming page just before the new content is first rendered. The canonical use is persisting state — scroll position, filter selections, the item you were hovering — into sessionStorage on exit and restoring it on entry:
// outgoing page
window.addEventListener("pageswap", (event) => {
if (event.viewTransition) {
sessionStorage.setItem("lastFilter", currentFilter);
}
});
// incoming page
window.addEventListener("pagereveal", (event) => {
if (event.viewTransition) {
const last = sessionStorage.getItem("lastFilter");
if (last) applyFilter(last);
}
});
The event.viewTransition check matters: both events fire on every navigation, and the property is only present when a transition actually runs. Restoring state inside pagereveal means it’s part of the incoming snapshot — the user sees the restored page animate in, rather than a flash of default state that then mutates.
You can also set transition types per navigation here, by calling event.viewTransition.types.add() in pagereveal (and reading the navigation information from the event to decide which type applies). Firefox hasn’t shipped cross-document transitions yet, so this half of the story remains Chromium 126+ and Safari 18.2+.
The gotchas nobody mentions
Cross-document transitions have a hard 4-second timeout: if the new page’s rendering pipeline doesn’t produce a frame in time, the browser silently skips the animation and does a plain swap. Slow server responses, render-blocking scripts, and heavy web fonts are the usual culprits. The fix isn’t in the transition code — it’s ordinary performance work. Sub-second server responses and non-blocking fonts keep the success rate high.
Other practical pitfalls:
- It’s one root per document. Every element gets an implicit unique name only at the root; two elements sharing an explicit name throws — including the root itself if you’ve named something
rootaccidentally. - The snapshot is a screenshot. The old-state capture is a static image; videos and animations freeze during the transition. The new capture is live, so entrances look fine but exits won’t animate internally.
- Fixed-position elements jump. Because the root snapshot is composited, elements fixed relative to the viewport can appear to shift during the animation. A common workaround is excluding sticky headers and re-anchoring them with their own transition names.
- BFCache interacts awkwardly. A back-navigation served from the back/forward cache may skip the transition entirely, since there’s no fresh render to animate into. Testing back navigation needs cache-busting or the
pagerevealpath. - Skip signals exist — use them.
transition.skipTransition()abandons a running transition, and theviewtransitionerror-adjacent skip paths let you bail out if your callback throws. In SPAs, wrap state updates so a throwing callback doesn’t leave the page captured mid-flight.
Feature detection without breakage
The design goal is that unsupported browsers see nothing unusual: document.startViewTransition guards the SPA path, and the @view-transition rule is simply ignored where unrecognized. CSS-wide enhancement checks are cleaner with CSS.supports:
const sameDoc =
typeof document.startViewTransition === "function";
const crossDoc =
CSS.supports("view-transition-name", "none") &&
CSS.supports("(view-transition: auto)");
In practice you rarely need the second check — the CSS opt-in degrades on its own. What you should avoid is coupling functional behavior to transitions: navigation must work, forms must submit, and state must persist whether or not an animation runs. Treat every transition as decoration, and the unsupported-browser path stays free of regressions.
What to build first
If you maintain an MPA with a listing → detail flow, the highest-value five minutes you can spend is the two-line @view-transition opt-in plus a shared view-transition-name on the listing image and the detail image. That alone produces the morphing-card effect that used to require a SPA rewrite. Then add pageswap/pagereveal state persistence for filters and scroll, and only reach for transition types when you genuinely need directional animations.
The full API surface is documented on MDN, with Chrome-specific guides for same-document and cross-document transitions. Browser support details: same-document transitions are in Chrome 111+, Safari 18+, and Firefox 144+; cross-document in Chrome 126+ and Safari 18.2+, with Firefox still working through it. The gap is closing — and unlike most cutting-edge web APIs, this one punishes nobody while it closes.