On October 14, 2025, the View Transitions API crossed a line that changes how you are allowed to argue about frontend architecture. That was the day same-document view transitions became Baseline Newly available, which means Chrome, Safari, and Firefox have all shipped them. The cross-document version, the one that animates across a full page load, had already landed in Chrome 126 and Safari 18.2. A server-rendered link click can now cross-fade, slide, or fly a shared element between two HTML documents with no client-side router anywhere in the stack.

For about a decade, "we need smooth, app-like navigation" was a load-bearing reason teams reached for a single-page app. That reason is mostly gone. What replaces it is a smaller and sharper question about where complexity should live, and the honest answer is more interesting than the demos suggest.

What single-page apps were really for

Single-page apps did not win on a whim. A full navigation in 2014 meant a white flash, a lost scroll position, and any in-progress state thrown away. The browser tore down one document and built another. Client-side routing existed to dodge that teardown: keep the document alive, swap the DOM yourself, and you can animate between two states because both states share a runtime.

That one capability justified an enormous amount of machinery. A router, a hydration pass, a bundler tuned to code-split that router, and a mental model where navigation is a state change rather than a request. The animation was never the expensive part. The expensive part was everything you shipped to stop the document from unloading so the animation could happen at all.

The counter-movement was already underway. Astro popularized islands architecture, where the page ships as static HTML and only interactive widgets hydrate. Qwik pushed resumability. The pitch in both cases was the same: stop sending a framework runtime to render content that never changes. The one thing these server-first models could not easily reclaim was the smooth transition between pages, because a real navigation still unloaded the document.

View Transitions erase that last asymmetry. The browser captures the old page, captures the new page, and animates between them itself, even when the navigation is a genuine document swap. A server-rendered multi-page app now gets the visual polish that used to require keeping a JavaScript application resident in memory.

Four nested translucent glass layers floating in stacked isometric depth.

How the API actually works

There are two entry points, and conflating them causes most of the confusion. One swaps content inside a live page. The other navigates between separate documents.

In a single-page app you wrap your DOM update in document.startViewTransition(). The browser screenshots the current state, runs your callback to mutate the DOM, screenshots the new state, and animates between the two.

// Same-document (SPA): you trigger the transition explicitly.
document.startViewTransition(() => {
  // Mutate the DOM however your framework does it.
  renderRoute(nextRoute);
});

In a multi-page app there is no callback, because no single runtime spans the two pages. You opt in declaratively, in CSS, on both the page you leave and the page you land on.

/* On every page that should participate, leaving and landing. */
@view-transition {
  navigation: auto;
}

That is the entire opt-in. With navigation: auto set on both documents, same-origin navigations the browser classifies as push, replace, or traverse will animate. Typing a URL into the address bar, reloading, or following a bookmark will not. This replaced an older <meta name="view-transition"> tag that still appears in stale tutorials. If you are copying that meta tag from a blog post, you are copying a deprecated API.

Underneath, both paths build the same structure: a tree of pseudo-elements layered over the page for the duration of the transition. Any element you tag with a view-transition-name becomes its own animated group.

/* The same name on both pages makes the element morph between them. */
.article-hero {
  view-transition-name: hero;
}

The browser generates ::view-transition-group(hero), then an ::view-transition-image-pair(hero) holding ::view-transition-old(hero) and ::view-transition-new(hero). The old one is a static snapshot of where the element was; the new one is the live element in its new position. By default the browser cross-fades the pair and tweens position and size, which is why a thumbnail on a list page can appear to fly into the header image on a detail page. You write CSS animations against those pseudo-elements to override the defaults.

You get something before you name anything. With nothing tagged, the browser still snapshots the whole page under the implicit root name and cross-fades old into new, so the two-line opt-in alone produces a soft fade between documents. Named elements layer specific morphs on top of that baseline. The same-document path also hands back a ViewTransition object whose .ready, .updateCallbackDone, and .finished promises let you sequence work around the animation or bail out early with skipTransition().

Because a cross-document transition shares no JavaScript, two events bridge the gap. pageswap fires on the outgoing page right before its snapshots are taken, and pagereveal fires on the incoming page before its first render. Both hand you the active transition object, so you can rename elements on the fly or tag the navigation with a type that CSS can branch on.

// Cross-document: no shared runtime, so events bridge the two pages.
window.addEventListener("pagereveal", (event) => {
  if (event.viewTransition) {
    // Tag the transition; CSS can then branch on it with
    // :active-view-transition-type(forward).
    event.viewTransition.types.add("forward");
  }
});

Reading the support matrix honestly

The headline is real. The fine print matters if you ship to everyone, so here is the actual state across engines.

CapabilityChromium (Chrome / Edge)Safari (macOS + iOS)Firefox
Same-document startViewTransition()11118144
Cross-document @view-transition12618.2144 (partial)

Same-document transitions are Baseline. You can call startViewTransition() today and treat it as a normal platform feature. Cross-document is the newer and more consequential one for server-rendered sites: Chrome and Edge from 126, Safari from 18.2 on both macOS and iOS, and Firefox shipping partial support from 144. caniuse puts global coverage for the cross-document version above 85 percent.

The reason that number is safe to ship against is the failure mode. View Transitions are pure progressive enhancement. A browser that does not understand @view-transition ignores the rule and performs an ordinary navigation. There is no polyfill to load, no feature-detection branch to maintain, and no broken state on older engines. The worst case in a browser that lacks it is the experience every site shipped in 2023: the page just changes.

Vector illustration of a dissolving translucent page inside a stopwatch arc.

The trade-offs the demos skip

A two-line CSS opt-in makes this look free. It is not. The defaults are tuned for a particular kind of page, and several of the failure modes are silent.

The sharpest edge is a four-second timeout that fails without warning. On a cross-document transition, the browser holds the old snapshot on screen while it waits for the new page to reach a renderable state. If that takes longer than four seconds, the transition is abandoned and you get a hard cut, with nothing in the console explaining why. Network latency, a slow time to first byte, render-blocking resources: all of it counts against that budget. The transition that looked perfect on localhost can evaporate on a real connection.

This inverts a familiar assumption. With a client-side router, a slow data fetch shows a spinner inside an app that stays put. With a cross-document transition, a slow server response shows no animation at all. The mechanism rewards the same discipline that keeps Interaction to Next Paint under 200 ms: a fast time to first byte and a first render unblocked by scripts. A site with a weak TTFB watches its transitions vanish on exactly the connections that need the reassurance most.

The second trap is naming. Every view-transition-name must be unique within a single snapshot. Tag ten cards in a list with view-transition-name: card and you have ten elements claiming one identity, which throws the transition out entirely. For a while the only fix was generating a unique name per item by hand. Chrome 137 added two escapes: view-transition-class, which lets many elements share styling without sharing a name, and view-transition-name: match-element, which mints a stable identity per element automatically. Both are Chromium-only so far, so a hand-rolled naming scheme is still the portable choice.

Smaller surprises stack up. Generated snapshots default to object-fit: fill, so an image whose aspect ratio changes between pages gets stretched into a smear until you set object-fit: cover on the pseudo-element. Only one view transition can run at a time, so rapid clicking has to interrupt the previous one cleanly. And the whole feature is same-origin: a navigation to another origin never animates, by design.

Layout has its own surprise. A named element is lifted out of normal flow into a fixed-position overlay for the length of the transition, which is how the browser animates it independently. A sticky header or fixed footer you did not name can therefore be overlapped, or appear to jump, as a sliding snapshot passes over it. The fix is counterintuitive: give that element its own view-transition-name even though it is not the thing you set out to animate, so the browser keeps it in the transition's paint order.

Accessibility is the part most likely to ship broken. During a transition both the old and new content exist in the DOM at once, which can scramble focus, lose a screen reader's reading position, and fire live-region announcements at the wrong moment. Motion itself is a hazard: a large shared-element flight can be physically nauseating for some people. Respecting prefers-reduced-motion is not a nicety here. A reduced-motion query that collapses the transition to a plain fade, or removes it, belongs in the same commit as the transition itself.

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

None of this makes the feature a bad bet. It does mean the complexity did not disappear, it moved. It left the JavaScript router and reappeared as a naming discipline, a render budget, and an accessibility checklist. That list is shorter than a router. It is not empty.

Isometric tech illustration of a tower and slab connected by a glowing bridge.

Where this leaves the single-page app

So is the SPA finished? No, and anyone selling that is overreaching. View Transitions remove one justification, the strongest cosmetic one, and leave the rest standing.

What a real navigation still cannot do is preserve live state. A music player that keeps playing as you browse, an editor with an unsaved buffer, optimistic UI that updates before the server confirms, a websocket you do not want to reopen on every click: each of these needs a persistent runtime, and a document swap destroys it. If your product's value lives in state that outlasts any single page, client-side routing still earns its weight.

Framework authors have already internalized the split. Astro supports native cross-document transitions directly, and its own docs are unusually candid about its JavaScript router, the <ClientRouter /> component. They write that using it "will increasingly become unnecessary" as browser support grows, and tell readers to "keep up with the current state of browser APIs so you can decide whether you still need Astro's client-side routing." A framework telling you that you might not need its feature is worth listening to. The choice between Astro and Qwik for a content-first site now turns even harder on whether you have genuine application state or just pages that need to look good when they change.

React is moving from the opposite direction toward the same primitive. Its experimental <ViewTransition> component, still Canary-only as of the React Labs update in April 2025, wraps elements and animates them through the browser's API rather than a JavaScript animation library, with an addTransitionType call to vary the animation by cause. Next.js exposes a viewTransition flag in its config to switch it on. It rhymes with React's other recent bet, handing memoization to a compiler instead of hand-written useMemo calls; the adoption curve for that compiler is worth watching for the same reason. The router, whether it lives on the server or the client, is becoming a thin layer that defers to one browser primitive. That is the more durable story than "MPAs won." The animation layer is being standardized out of userland.

The SPA frameworks are wiring into the same primitive. SvelteKit added an onNavigate hook in 1.24 whose entire job is to hand the navigation to document.startViewTransition. The React side carries its own footgun: call the API around a state update without flushSync and React batches the change, so the DOM has not updated when the browser takes its snapshot and the animation silently does nothing. The primitive is shared. The sharp edges around it are still per-framework.

One gap remains before a multi-page app truly matches a single-page app on feel. Smoothness is solved; instant is not. A client-side router can render the next view from data already in memory, so navigation feels immediate. A cross-document navigation still makes a request. The browser's answer is the Speculation Rules API, which prerenders or prefetches likely next pages so the document is ready before the click lands. Pair prerendering with a view transition and a server-rendered site can feel both instant and animated. The catch is support: Speculation Rules is effectively Chromium-only right now, with Safari behind a flag and Firefox committed only to the prefetch half. So the complete "feels like an app" multi-page experience is a Chrome story today, while the transition half is genuinely cross-browser.

The rules themselves are a small JSON block in the page, naming which links to prepare and how eagerly to do it:

<script type="speculationrules">
{
  "prerender": [
    { "where": { "href_matches": "/*" }, "eagerness": "moderate" }
  ]
}
</script>

Picking a side

For a content-driven site, a docs portal, a storefront, an editorial publication, the calculus has flipped. Reaching for a client-side router to get smooth navigation now solves a problem the platform already solved. Ship server-rendered HTML, add a two-line @view-transition rule, name a few shared elements, respect reduced motion, and keep your time to first byte under the four-second budget. You get most of the feel of an SPA for almost none of the cost.

For a stateful application, nothing changed except that your transitions can now route through a standard API instead of a bespoke one. Keep the runtime, keep the router, and let the browser handle the animating.

The open question is timing. Firefox still has to finish cross-document support, and Speculation Rules has to escape Chromium before "instant and smooth multi-page app" is a promise you can make everywhere rather than an enhancement you layer on. The platform is converging on a model where the server renders and the browser animates. Whether your next project should bet on that today comes down to one honest question: are you building an application, or are you building pages that happen to need to look good when they change?