View Transitions API for Multi-Page Apps Without a Framework
Implement smooth cross-document page transitions in vanilla server-rendered apps using the View Transitions API, with fallback strategies for unsupported browsers.

View Transitions API for Multi-Page Apps Without a Framework
Server-rendered multi-page applications (MPAs) have always had a smoothness gap compared to SPAs: every navigation triggers a full page reload, losing scroll position and flashing a white screen. The View Transitions API closes that gap without requiring a JavaScript framework, by letting the browser animate between two documents using a declarative CSS rule and, optionally, a small script for fine-grained control.
What the View Transitions API actually does
The API has two modes. Same-document transitions (used inside SPAs) require a call to document.startViewTransition(callback) where the callback updates the DOM. Cross-document transitions (MPA navigations) work declaratively: the browser captures a snapshot of the outgoing page, loads the new page, captures a snapshot of the incoming page, and then crossfades between the two. As of Chrome 126 and Edge 126, this cross-document behaviour is enabled via a single CSS rule.
Under the hood the browser runs a three-step pipeline: capture – it takes a screenshot of the current visual state; morph – it applies a CSS animation (default crossfade) to the captured snapshot; animate – it reveals the new page’s live content. The animation is implemented using a set of ::view-transition-group pseudo-elements that you can target with custom keyframes if the default crossfade isn’t enough.
For scripted same-document transitions you call document.startViewTransition(() => updateDOM()). For cross-document you write @view-transition { navigation: auto; } in your CSS. That’s the entire API surface for the basic case.
Setting up cross-document transitions without a framework
Add the following CSS to every page of your site (or to a shared stylesheet):
@view-transition {
navigation: auto;
}That’s it. On same-origin navigations (link clicks, back/forward, location.assign, etc.) the browser will now crossfade between pages. No JavaScript required. A simple <a href="/page2"> works out of the box.
If you need more control – for example, to skip the transition on slow connections or to wait for a critical image to load – you can hook into the navigation API (the new browser-level navigation event, not the old popstate). The navigate event fires before the transition starts:
navigation.addEventListener('navigate', (event) => {
if (!document.startViewTransition) {
return; // browser doesn't support view transitions
}
// Skip transition on back/forward to avoid disorienting the user
if (event.navigationType === 'traverse') {
return;
}
// Hold the transition until a promise resolves
event.transitionWhile(new Promise((resolve) => {
// e.g., wait for hero image to load
const img = new Image();
img.src = '/hero.jpg';
img.onload = resolve;
img.onerror = resolve;
}));
});This snippet uses event.transitionWhile(promise) to delay the animation until the promise settles. The browser will keep the outgoing snapshot visible until then. If the promise rejects, the transition is cancelled and a normal navigation occurs.
How to handle shared elements for smooth MPA transitions
A plain crossfade is better than a white flash, but it still feels generic. To make elements “morph” from one page to the next – like a product image that moves and resizes, or a blog title that slides – you assign a view-transition-name to those elements on both pages.
/* On the index page */
.post-list .post-title {
view-transition-name: post-title;
}
/* On the individual post page */
.post-header h1 {
view-transition-name: post-title;
}The browser matches elements by name across the two pages and animates their position and size changes, while everything else crossfades. The names must be unique within a page (you can’t reuse the same name for multiple elements on one page). For lists of items (e.g., a grid of product cards) you need to generate unique names dynamically, often by appending an ID or index to the view-transition-name value. This can be done server-side or via a small script that sets the property before the transition starts.
A critical gotcha: an element with view-transition-name is removed from the normal painting order during the transition. The browser draws it in a separate layer above the page. If you have multiple named elements that overlap, or if you rely on CSS stacking contexts (e.g., z-index), the visual result can be unexpected. Test on each page to ensure no layout shifts or clipping occur.
For responsive layouts, you might combine view-transition-name with CSS Container Queries in Production to ensure elements adapt to different containers across pages. Container queries let the element’s size depend on its parent rather than the viewport, which is useful when the same component lives in different layout contexts on the index and detail pages.
Comparison: Declarative CSS-only vs JavaScript-controlled transitions
| Aspect | Declarative (CSS-only) | JavaScript-controlled |
|---|---|---|
| Setup | One line of CSS | CSS + navigate event handler |
| Timing | Browser decides when to start and end | Developer controls via transitionWhile(promise) |
| Abort | Not possible | Can skip by returning early from the event |
| Custom animations | Via ::view-transition-group keyframes |
Same, but can also conditionally apply different keyframes |
| Browser support | Chrome/Edge 126+ (other browsers ignore) | Same, plus need navigation API |
| Use case | Simple crossfade on all navigations | Waiting for resources, disabling on certain navigations, intercepting form submissions |
The declarative approach is zero-JS and works for the majority of sites. The JavaScript route is necessary when you need to hold the transition for async work, skip it on back-navigation, or handle POST form submissions (which don’t trigger the transition automatically because they are not “navigation” in the browser’s sense by default – you must intercept the form with event.preventDefault() and call document.startViewTransition() manually).
Failure modes and gotchas
Browser support. Only Chromium-based browsers (Chrome, Edge, Opera) version 126+ support cross-document view transitions. Firefox and Safari have not shipped it at the time of writing. The CSS rule is silently ignored in unsupported browsers – the page loads normally. Always test without transitions enabled to ensure your pages degrade gracefully.
Nested navigation. The transition applies only to the top-level frame. Iframes, embeds, or <object> elements do not participate. A navigation inside an iframe will not trigger a cross-document transition.
Large DOMs. The snapshot phase can be expensive on pages with thousands of elements or complex paint effects (like heavy box-shadow, filter, or backdrop-filter). On low-end devices this can cause jank or even a visible freeze. Consider disabling transitions on pages with heavy content, or conditionally enabling them only on simpler views. You can test performance with the browser’s Performance panel – look for long tasks during the startViewTransition call. Profiling with Lighthouse’s performance audit can also reveal if the transition contributes to layout shifts or long main-thread tasks.
Accessibility and motion sensitivity. The default crossfade may cause discomfort for users who prefer reduced motion. Respect the prefers-reduced-motion media query by disabling transitions entirely or replacing them with a subtle fade:
@media (prefers-reduced-motion: reduce) {
@view-transition {
navigation: none;
}
}Alternatively, you can keep the transition but shorten its duration to 0ms, effectively snapping the page without a visual flash.
Form submissions and POST navigations. Automatic transitions only fire for GET navigations (link clicks, location.assign, back/forward). A <form method="post"> will not trigger the transition. To animate form submissions you must intercept the submit event, call event.preventDefault(), and use document.startViewTransition() to update the page manually (same-document style) or issue a fetch and replace the document. This is more involved and may defeat the purpose of an MPA, but it’s possible.
Real-world example: a simple blog with smooth page transitions
Let’s build a minimal two-page blog: an index page listing posts, and an individual post page. The goal is to morph the post title from the list item into the full-page heading, while the rest of the content crossfades.
Step 1: Add the CSS rule globally.
/* styles.css */
@view-transition {
navigation: auto;
}
/* Name the shared element */
.post-title {
view-transition-name: post-title;
}Step 2: Mark up the index page.
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<ul class="post-list">
<li>
<a href="/post/hello-world">
<h2 class="post-title">Hello World</h2>
</a>
<p class="excerpt">First post</p>
</li>
<li>
<a href="/post/second-post">
<h2 class="post-title">Second Post</h2>
</a>
<p class="excerpt">Another one</p>
</li>
</ul>
</body>
</html>Step 3: Mark up the post page.
<!-- post.html (server-rendered for /post/hello-world) -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<article>
<header>
<h1 class="post-title">Hello World</h1>
<time>2025-04-01</time>
</header>
<p>This is the full post content.</p>
</article>
</body>
</html>Step 4: (Optional) Graceful fallback for unsupported browsers.
<script>
if (!document.startViewTransition) {
// Remove the CSS rule? No need – it's ignored. But you might
// want to disable any JS that depends on transitions.
// For a fully graceful fallback, just let the page load normally.
}
</script>When a user clicks a post link, the browser captures the index page, loads the new page, and morphs the h2 into the h1 because they share view-transition-name: post-title. The excerpt and other content crossfade. The result feels fluid without any JavaScript framework.
If you need to wait for the post content’s hero image before starting the transition, use the navigate event as shown earlier. For a simple blog, the CSS-only approach is sufficient.
Key takeaways
- Cross-document view transitions require a single CSS rule (
@view-transition { navigation: auto; }) and work in Chrome/Edge 126+ without any JavaScript. - Shared elements between pages (like a blog title or product image) morph smoothly when given the same
view-transition-nameon both pages. - The JavaScript
navigationAPI’snavigateevent gives you control over timing, abort, and conditional transitions – use it when you need to wait for resources or exclude certain navigation types. - Always test on unsupported browsers – the fallback is a normal page load, which is acceptable, but you should verify no unintended side effects.
- Large DOMs and complex paint effects can cause jank during the snapshot phase; profile on low-end devices and consider disabling transitions on heavy pages.
- Unlike techniques such as Streaming SSR with Suspense that progressively render content, view transitions provide visual continuity across navigations without altering the server-rendering model. Both approaches improve perceived performance, but view transitions are purely presentational and work with any backend.
Frequently asked questions
- Does the View Transitions API work on back/forward browser navigation?
- Yes, as long as both pages have the `@view-transition { navigation: auto; }` rule. The browser treats back/forward navigations the same as link clicks, so transitions apply unless you explicitly skip them via the `navigate` event.
- Will this work on pages loaded via form submissions or only links?
- By default, only same-origin `GET` navigations (links, `location.assign`, browser back/forward) trigger the transition. Form submissions with `POST` or `GET` may not fire the transition automatically; you need to intercept the form with JavaScript and call `document.startViewTransition()` inside a `navigate` event handler.
- How do I handle browsers that do not support the View Transitions API?
- The CSS `@view-transition` rule is ignored by unsupported browsers, so they simply perform a normal page load with no animation—no harm. For JavaScript-based transitions, check `if ('startViewTransition' in document)` before using it, and fall back to `location.href` or `window.location.assign()` otherwise.


