SPA-like Page Transitions in an MPA with 2 Lines of CSS — Cross-document View Transitions
Honestly, a significant chunk of the reasons to switch to an SPA came down to just one thing: smooth page transitions. That was it. But now that reason is starting to waver.
With Chrome 126 shipping Cross-document View Transitions in 2024 and Safari 18.2 following suit, MPA sites can now achieve SPA-level transition effects with CSS alone — covering roughly 75% of global browser market share. No need to wire up a routing library, layer on state management, or devise a client-side rendering strategy. Zero bytes added to your JavaScript bundle, your existing MPA structure left completely intact — just two lines added to a CSS file.
The Cross-document approach of the View Transitions API works by adding a single @view-transition declaration to the CSS of both the origin page and the destination page. This article breaks down everything from the core mechanics to real-world patterns like e-commerce card transitions, and pitfalls easy to miss like LCP impact — concretely enough that you can apply it to an existing site in under 10 minutes.
Table of Contents
Core Concepts
SPA Approach vs. MPA Approach — Differences in API Structure
The View Transitions API comes in two flavors. The earlier Same-document approach enters through a JavaScript document.startViewTransition() call and pairs well with SPA frameworks like React or Vue — it's a structure where you insert the transition effect at the moment you directly manipulate the DOM or change state.
I initially wondered whether to use the same approach in an MPA, but the answer was no. Since you're navigating between different HTML files, the approach itself had to be different. The Cross-document approach works with CSS alone, no JavaScript required.
/* Add to the CSS of both the origin page and the destination page */
@view-transition {
navigation: auto;
}That's all there is to it. For navigation within the same origin, the browser handles the transition effect automatically. However, navigation: auto does not apply in a few cases: links with a download attribute, links with target="_blank", and form submissions will not trigger a Cross-document transition. Think of it as working only for regular anchor navigation within the same origin.
Under the hood: Just before navigation, the browser captures a screenshot of the current screen. After the new page renders, it composites the two states as
::view-transition-old(root)and::view-transition-new(root)pseudo-elements to produce the animation. The default is a full-screen crossfade.
Shared Element Transition
The basic crossfade is decent, but the real magic of MPA View Transitions lies in Shared Element Transitions. By giving corresponding elements on both pages the same name via the view-transition-name CSS property, the browser recognizes them as "the same thing" and produces a natural morphing animation between them.
/* List page */
.product-thumbnail {
view-transition-name: product-hero;
}
/* Detail page */
.product-hero-image {
view-transition-name: product-hero;
}This is how a small thumbnail on a list page naturally expands into the hero image on the detail page. That transition effect you've seen in native apps — built with just a few lines of CSS.
Note:
view-transition-namemust be unique within a single page. If two or more elements share the same name, the transition breaks. When the same card appears multiple times — as on a list page — you'll need to dynamically assign names.
Animating by Navigation Direction — View Transition Types
This feature was added in the 2025 update. You can apply different animations for forward and backward navigation, creating an experience similar to stack navigation in a mobile app. However, using this branching does require a small amount of JavaScript to specify the transition type via the Navigation API — it's not a fully CSS-only approach.
/* Forward navigation: slide left */
:active-view-transition-type(forward) {
&::view-transition-old(root) { animation-name: slide-out-left; }
&::view-transition-new(root) { animation-name: slide-in-right; }
}
/* Backward navigation: slide right */
:active-view-transition-type(backward) {
&::view-transition-old(root) { animation-name: slide-out-right; }
&::view-transition-new(root) { animation-name: slide-in-left; }
}
/* Keyframe definitions referenced by the animation-name values above — omitting these means no animation will play */
@keyframes slide-out-left {
to { transform: translateX(-100%); }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
}
@keyframes slide-out-right {
to { transform: translateX(100%); }
}
@keyframes slide-in-left {
from { transform: translateX(-100%); }
}Using view-transition-class, you can group multiple shared elements together and apply the same animation settings to all of them. For example, to group product images under a product-image class:
.product-card img {
view-transition-name: var(--product-transition-name); /* unique name per element */
view-transition-class: product-image; /* group class */
}
/* Apply shared settings to the entire product-image group */
::view-transition-group(product-image) {
animation-duration: 0.4s;
animation-timing-function: ease-in-out;
}Practical Application
Example 1: E-commerce Product Card → Detail Page Transition
The most common pattern in real-world work. Clicking a thumbnail in a card list causes that image to morph and expand into the hero area of the detail page.
Because there are multiple cards, and view-transition-name must be unique, names must be assigned dynamically. Using an inline style is the simplest approach. Whether you're using PHP, Django, or Rails, just output a name that includes the id when rendering the HTML server-side.
<!-- List page HTML -->
<!-- CSS is added via a <link> stylesheet or <style> tag in <head> -->
<a href="/products/42">
<img
src="/images/product-42.jpg"
style="view-transition-name: product-42"
alt="Product name"
/>
</a>
<a href="/products/43">
<img
src="/images/product-43.jpg"
style="view-transition-name: product-43"
alt="Product name"
/>
</a>/* List page CSS — add to the stylesheet in <head> */
@view-transition {
navigation: auto;
}
/* Required: handling for users sensitive to motion */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}<!-- Detail page: /products/42 -->
<img
src="/images/product-42-hero.jpg"
style="view-transition-name: product-42"
class="hero-image"
alt="Product hero image"
/>/* Detail page CSS — both pages need the same @view-transition declaration */
@view-transition {
navigation: auto;
}
.hero-image {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}| Point | Explanation |
|---|---|
Using inline style |
A class would assign the same name to multiple cards, so inline styles are needed to give each element a unique name |
product-{id} pattern |
The simplest way to keep names consistent between the list and detail pages |
prefers-reduced-motion |
Required for accessibility — users with vestibular disorders and others sensitive to motion |
One more word on prefers-reduced-motion: this isn't something you add "if you have time." For people with vestibular disorders, sudden screen movement can genuinely cause dizziness or nausea, and allowing them to control this through system settings is a fundamental accessibility contract. I've seen this skipped fairly often in practice — even developers who primarily work on the backend should make a habit of including it whenever implementing screen transitions.
Example 2: Applying Site-wide to an Astro Site
If you're using Astro, there's an even simpler approach. Adding the <ViewTransitions /> component introduced in Astro 3.x to your layout activates View Transitions across the entire site. It even handles fallbacks for unsupported browsers, so browser compatibility is less of a concern.
---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{Astro.props.title}</title>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>---
// Specifying a transition name on a specific element
const { productId } = Astro.props;
---
<img
src={`/images/product-${productId}.jpg`}
transition:name={`product-${productId}`}
alt="Product image"
/>Astro lets you manage view-transition-name declaratively with the transition:name directive, which is far more maintainable than writing inline style attributes by hand.
Pros and Cons
Advantages
Honestly, looking at the list of advantages makes you wonder "if this works, why is everyone still using SPAs?" — but in reality, support coverage and performance issues do get in the way. That said, the advantages first:
Since no JavaScript is needed at all, there's no impact on bundle size. Because transitions are handled directly in the browser's compositor layer, the GPU can maintain 60fps animations without burdening the main thread. We verified this on our own team — smooth performance with no CPU load was clearly noticeable. Graceful degradation is also naturally handled: in unsupported browsers, navigation simply works normally without any transition effect. Unlike SPAs, actual HTML document navigation means crawlers can fully read each page, and being able to improve UX just by modifying a CSS file — without touching the existing MPA structure — is a major draw.
| Item | Details |
|---|---|
| Zero JavaScript added | Completed with CSS alone, no impact on bundle size |
| Hardware acceleration | GPU handles it directly without burdening the main thread — 60fps animations expected |
| Progressive enhancement | In unsupported browsers, works normally without transitions |
| SEO-friendly | Actual HTML document navigation means crawlers can fully read each page |
| Existing code preserved | MPA structure unchanged — UX improved through CSS edits alone |
Disadvantages and Caveats
LCP impact is the most immediate concern. Core Web Vitals research has reported an LCP increase of approximately +70ms on mobile for repeat visits. This is particularly important to watch when applying transitions to LCP elements themselves, such as hero images. If the image transitioning to the detail page is also the element that determines your site's LCP, the transition effect could actually hurt your search ranking.
The 4-second limit also catches people off guard in practice more than you'd expect. If a page takes more than 4 seconds to load, Chrome simply skips the transition — so no matter how polished your transition effect is, users won't see it if performance is poor.
| Item | Details | Mitigation |
|---|---|---|
| LCP impact | LCP increase of +70ms reported on mobile for repeat visits | Be cautious about applying transitions to LCP elements |
| 4-second limit | Chrome skips the transition if page load exceeds 4 seconds | Meeting Core Web Vitals benchmarks is a prerequisite |
| Name uniqueness constraint | Two or more elements with the same view-transition-name breaks the transition |
Dynamic ID-based name assignment required |
| Same-origin restriction | Cannot be applied to navigation between different domains | Navigation structure must be designed within the same origin |
| Firefox not supported | Cross-document transitions do not work in Firefox | Design with graceful degradation so navigation works without transitions |
| Screen reader confusion | Two DOMs coexisting during a transition can cause confusion | prefers-reduced-motion handling is mandatory |
LCP (Largest Contentful Paint): A Core Web Vitals metric that measures when the largest visible content element within the viewport finishes rendering. Hero images and large text blocks are typical candidates, and it also affects Google search ranking.
The Most Common Mistakes in Practice
-
Duplicate
view-transition-nameassignments: Applyingview-transition-namevia the same CSS class to all cards on a list page results in multiple elements sharing the same name on a single page, breaking the transition. Names must be dynamically assigned via inline styles or server-side rendering to ensure each element has a unique name. -
Missing
prefers-reduced-motionhandling: When focused on crafting an impressive transition effect, it's easy to forget handling for users sensitive to motion. It's tedious to add later, so it's much easier to include it alongside@view-transitionduring initial setup. -
Applying transitions without optimizing page performance: View Transitions can actually backfire when page load is slow. The transition may be skipped due to the 4-second limit, or LCP metrics can worsen. It's worth checking your Core Web Vitals baselines first.
Closing Thoughts
The Cross-document approach to View Transitions is, at this point in time, the most practical option for bringing SPA-level UX advantages to an existing multi-page architecture — using CSS alone. Firefox's lack of support is a limitation, but it's handled naturally through graceful degradation, and with cross-browser support included as an Interop 2025 goal, coverage will continue to expand.
Here's what you can start with right now:
- Add
@view-transition { navigation: auto; }to the CSS files of both pages on your existing MPA site. A default crossfade transition will be applied immediately. - Add a
@media (prefers-reduced-motion: reduce)block right below it to meet accessibility requirements. - Pick a representative transition point and apply a Shared Element Transition with
view-transition-name. The View Transitions panel in Chrome DevTools lets you visualize each phase of the transition for debugging — it's a big help when setting things up for the first time.
References
- View Transition API | MDN
- Cross-document view transitions for MPA | Chrome for Developers
- What's new in view transitions (2025 update) | Chrome for Developers
- @view-transition CSS at-rule | MDN
- Using View Transition Types | MDN
- How to implement view transitions in multi-page apps | LogRocket Blog
- View Transitions | Astro Docs
- View Transition API & meta frameworks: a practical guide | Bejamas
- MPA View Transitions Deep Dive | Bram.us
- The Impact of CSS View Transitions on Web Performance | Core Web Vitals
- 7 View Transitions Recipes to Try | CSS-Tricks