Skip to main content
MEWA STUDIO

Cross-document View Transitions, the fluidity of an SPA on a multi-page site

Published on September 4th, 2026|11 min read
CSSdevelopmentUX

Cross-document View Transitions are ten times more widely used than the transitions single-page applications rely on. Four lines of CSS turn them on. This article builds a complete transition, then sets out the limit that matters in production.

Rounded rectangular frames outlined in pink and blue neon, nested in perspective towards the back of a dark room

0.114% of pages loaded in Chrome run an animated transition between two documents. Transitions inside a single document, the ones single-page applications trigger, sit at 0.011% (opens in a new tab). Ten times fewer. The measurement (opens in a new tab) runs against intuition. The version considered hardest, animating the move from one document to another, is the one taking hold.

These transitions have a name, cross-document View Transitions. Chrome has shipped them since version 126 (opens in a new tab) in June 2024, Safari since 18.2 (opens in a new tab) in December 2024. They animate the move from one page to the next on an ordinary multi-page site, the kind that serves HTML and lets the browser navigate. No JavaScript router, no state to preserve, no framework. Turning them on takes four lines of CSS.

The topic goes beyond syntax. This article starts with what the browser actually destroys between two pages, the gap that justified moving routing to the client. It then builds a complete transition, from the default fade to the elements that persist from one page to the next, then to animations that change with the direction of travel. It ends with the limit that matters in production. This transition speeds up no page load. Knowing what to pair it with is part of the job.

The gap between two documents

A classic navigation destroys one document and builds another. Browsers have long softened the operation by keeping the old page on screen until the new one has something to paint. The result is not a white screen, it is an abrupt swap. One visual state replaces the other with nothing in between.

What the visitor loses at that moment comes down to one question. Which element became which element? The thumbnail clicked in a grid becomes the hero image of the next page, the card heading becomes the article heading. Nothing shows it. That thread of continuity has to be rebuilt by the brain on every navigation. It is an attention cost, of the same kind as those described in relation to the mental load of an interface (opens in a new tab).

Single-page applications (SPAs) solved this by never leaving the document. Routing moves into JavaScript, page changes become state changes and everything becomes animatable, since nothing is destroyed. The method works. It also shifts part of the browser's job into application code, scroll restoration, focus management, announcing page changes to screen readers, handling network errors and keeping state in sync (opens in a new tab). For an application, that trade is justified. For a marketing site, a catalogue, a blog or a documentation site, it was mostly being paid for the animation.

Turning View Transitions on in four lines of CSS

Activation is declared in the stylesheet, with an identical rule on the page being left and the page being opened.

css
@view-transition {
  navigation: auto;
}

Opting in to cross-document transitions

The rule has to appear in both documents. If only one of the two pages declares it, nothing happens, since the browser needs consent from the page it leaves as much as from the page it opens. This is the leading cause of a transition that never fires. It shows up most often when one section of the site loads a stylesheet different from the rest.

Firing then depends on strict conditions, spelled out in the MDN documentation (opens in a new tab) for the @view-transition rule.

ConditionWhat breaks it
Both documents share scheme, host and portA redirect to another origin anywhere in the chain, even an intermediate one
The @view-transition rule is present on both sidesA page served without the shared stylesheet
A push or replace navigation started from page contentA URL typed in the address bar or opened from a bookmark
A traverse navigationNothing, the browser's Back and Forward buttons are accepted

The conditions a cross-document transition needs in order to fire

Once those conditions are met, the default transition is a cross-fade across the whole page. The browser captures the old document just before it goes, captures the new one as soon as it is ready to paint, then fades one into the other. Nothing else to write. On a site whose pages share a header and a navigation bar, the gain is already clear, the header stops jumping on every click.

The pseudo-element tree the browser builds

To animate, the browser lays a tree of pseudo-elements over the new document, holding both captures.

text
::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)

The default transition tree

::view-transition-old(root) is the image of the old page, ::view-transition-new(root) the image of the new one. The group holding them carries the position and size animation, the pair handles the move from one to the other. By default this tree contains a single group named root, which explains the behaviour observed. The whole page is treated as one block, so it fades as one.

Naming what has to persist across pages

The real work starts when an element has to survive the navigation instead of disappearing with the rest. The view-transition-name property pulls it out of the root group and gives it one of its own.

css
/* List page */
.card-cover {
  view-transition-name: cover;
}

/* Article page */
.article-hero {
  view-transition-name: cover;
}

The same name in both documents

The name works as a meeting point. The browser finds cover in the old document then in the new one, concludes it is the same object and animates the move from one position and size to the other. The grid thumbnail slides and grows until it becomes the hero image. No coordinate is computed by hand, the browser measures both states and interpolates between them.

Two constraints govern this mechanism. The first explains most of the transitions that fail silently.

A name has to be unique in the document at capture time. A grid of twelve cards all declaring view-transition-name: cover produces twelve elements with the same name. The browser then abandons the entire transition. On a list page the rule cannot be static. It has to point at one specific card, the one the visitor just clicked.

The pageswap event, documented on MDN (opens in a new tab), makes that possible at the last moment, right before the outgoing document gives way.

javascript
window.addEventListener("pageswap", async (event) => {
  if (!event.viewTransition) return;

  const target = new URL(event.activation.entry.url);
  const link = document.querySelector(`a[href="${target.pathname}"]`);
  if (!link) return;

  const cover = link.querySelector(".card-cover");
  cover.style.viewTransitionName = "cover";

  await event.viewTransition.finished;
  cover.style.viewTransitionName = "none";
});

Naming the clicked card just before navigating

event.activation.entry.url gives the destination URL, which makes it possible to find the matching link in the page and name its image. Awaiting event.viewTransition.finished before removing the name gives the browser time to take its snapshot. The mirror event pagereveal, also documented on MDN (opens in a new tab), plays the same role on the arrival side, useful when a backwards navigation has to pick one target among several.

The second constraint is specific to cross-document transitions. The match-element value, which asks the browser to generate a unique name per element, only works within a single document. The identifiers it produces do not cross the boundary between two documents (opens in a new tab). The shortcut that simplifies transitions inside a single-page application so much is therefore unavailable here. Between two pages, names are explicit or there is no match.

Writing your own animations

The pseudo-elements animate like any other element, with animation and @keyframes.

css
::view-transition-old(root) {
  animation: 120ms ease-out both fade-out;
}

::view-transition-new(root) {
  animation: 200ms ease-in both fade-in;
}

::view-transition-group(cover) {
  animation-duration: 260ms;
  animation-timing-function: ease-in-out;
}

Replacing the default fade

The group carries movement and resizing, old and new carry the exit and the entrance. Separating the two makes it possible to send the old page out quickly then bring the new one in slightly slower, the asymmetry that creates a sense of direction.

To apply the same treatment to several named elements, view-transition-class avoids repeating rules.

css
.card-cover,
.article-hero {
  view-transition-class: media;
}

::view-transition-group(.media) {
  animation-duration: 260ms;
}

Grouping several transitions under one class

The universal selector (opens in a new tab) is still available to reach every group at once with ::view-transition-group(*). Its specificity is zero, which makes it a good base to override name by name afterwards.

One point of method saves hours. These rules have to exist in both documents, the one leaving as well as the one arriving, since each supplies half of the transition. A global stylesheet loaded across the whole site settles the question without further thought.

Telling forwards from backwards

The same movement in both directions feels wrong. The next page should come in from the right, the previous one return from the left. The types descriptor declares that information and the :active-view-transition-type() selector reads it on the root element, as its MDN page (opens in a new tab) sets out.

css
@view-transition {
  navigation: auto;
  types: forwards;
}

html:active-view-transition-type(forwards) {
  &::view-transition-old(root) {
    animation-name: slide-out-to-left;
  }
  &::view-transition-new(root) {
    animation-name: slide-in-from-right;
  }
}

html:active-view-transition-type(backwards) {
  &::view-transition-old(root) {
    animation-name: slide-out-to-right;
  }
  &::view-transition-new(root) {
    animation-name: slide-in-from-left;
  }
}

Different animations per direction

A type declared in CSS stays fixed, which is rarely enough. The real direction of a navigation depends on history, not on the page. The pagereveal event makes it possible to add the type as the transition starts, before the animations resolve.

javascript
window.addEventListener("pagereveal", (event) => {
  if (!event.viewTransition) return;

  const { from, entry, navigationType } = navigation.activation;

  if (navigationType !== "traverse" || !from) {
    event.viewTransition.types.add("forwards");
    return;
  }

  const backwards = entry.index < from.index;
  event.viewTransition.types.add(backwards ? "backwards" : "forwards");
});

Deriving direction from the navigation history

The history entry index tells the whole story. A destination whose index (opens in a new tab) is lower than the page being left means a backwards navigation. The type added before the transition starts then selects the right CSS rules. The visitor gets the expected reading direction when using the Back button.

This transition makes no page load faster

The point is worth stating plainly. A cross-document transition reduces no load time. The browser only captures the new document once it is ready to paint, so after the request, the response and the render. On a page that takes eight hundred milliseconds to respond, the transition starts after those eight hundred milliseconds and the animation adds to the total.

What the visitor gains is of a different nature, continuity. The change becomes readable, the link between the two states visible. The sense of fluidity in a single-page application actually comes from two separate things, the animation and the absence of waiting. View Transitions provide the first. The second is handled elsewhere, with the Speculation Rules API (opens in a new tab), which asks the browser to prepare the next page before the click.

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

Preparing the next page before the click

With the next page already rendered, the transition starts immediately on click and the illusion becomes complete. That mechanism is a subject in its own right, with its own trade-offs in bandwidth and server load. For now, the thing to remember is that the two techniques complement each other. One removes the wait and the other makes it readable when it remains.

A practical consequence follows for duration. A long transition delays the moment the visitor can act on the new page. The reference point sits in the browser's own stylesheet, which animates transition groups over a quarter of a second (opens in a new tab). Staying in that range keeps the transition on the side of continuity, stretching it moves it to the side of waiting.

Motion is not neutral

A full-screen page movement belongs to the family of animations that trigger vestibular disorders, nausea and dizziness for the people affected. The subject was covered in detail in relation to accessible interactions (opens in a new tab). The answer fits in a media query, and it means reducing motion rather than removing everything.

css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*) {
    animation-duration: 0s;
  }

  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 100ms;
  }
}

Reducing motion without losing the visual cue

Movement and size changes disappear, a short fade remains. The visitor keeps the signal that the page has changed without enduring the motion that produces it. Removing every animation is still an option, it is simply weaker, since the change cue goes with it.

Browser support and the decision to adopt

Support is not complete. Chrome and Edge since version 126, Safari since 18.2. Firefox does not have it yet (opens in a new tab), which keeps the feature at Baseline "limited availability" status. Firefox shipped transitions within a single document in version 144 (opens in a new tab) in October 2025, it is the cross-document half that is missing. Cross-document transitions are among the twenty focus areas of Interop 2026 (opens in a new tab), the annual agreement in which browser vendors commit to a shared list.

The behaviour in the absence of support is what makes adoption simple. An engine that does not know @view-transition ignores the rule, as it ignores any unknown at-rule. A visitor on Firefox gets the usual navigation, without animation and without error. Nothing to load, nothing to detect, no fallback to write. This is progressive enhancement at its most favourable, a feature whose absence takes nothing away from what was there before. The same reasoning applies to interactive sites when JavaScript fails (opens in a new tab).

That leaves the optional JavaScript used for dynamic naming. The pageswap and pagereveal events do not exist in Firefox either, which makes the if (!event.viewTransition) return; guard doubly useful. The listener never fires. If it did fire without an active transition, the function would stop immediately.

The decision comes down to this.

  • On a marketing site, a blog, a catalogue or a documentation site, turn it on without hesitation. Four lines of CSS for an immediate effect, the best effort-to-result ratio modern CSS offers
  • On an existing single-page application, nothing changes. document.startViewTransition() remains the right tool, Baseline since October 2025
  • On a project that was leaning towards a single-page architecture purely for transition fluidity, that argument is gone
  • On a constrained browser estate, intranet or public sector, turn it on anyway. Missing support degrades nothing
  • Before investing in element-by-element naming, check whether the default fade is already enough. It is enough more often than expected

What the client router keeps and what it loses

The choice between multi-page and single-page was long settled partly on appearance. A classic navigation gave an abrupt swap, a JavaScript router gave a transition. That gap is gone. A site serving static HTML gets the same visual continuity from four lines of CSS, with no dependency to install and no code to maintain.

The client router keeps what actually justified it, state shared across screens, interfaces that never reload, real time. It loses one argument, fluidity. For a marketing site, a blog or a catalogue, that argument no longer pays for a framework. It is one line less in a project budget and one dependency less to upgrade two years from now.