For years, smooth transitions between screens were almost exclusive to single-page apps. If you wanted to animate the change from one view to another, you usually ended up writing JavaScript, duplicating DOM states, fighting with overlays, or turning a multi-page site into an SPA just to control navigation.
That is changing with the View Transitions API. The core idea is simple: instead of capturing the "before" and "after" visual states yourself and animating them by hand, you ask the browser to do it for you. It takes a snapshot of the current state, lets you change the DOM (or lets the new page load), and then animates from the old snapshot to the new one.
The important part for frontend is that this now works on two levels: between states within the same page (the SPA case), and between real pages of a multi-page application —known as MPA— using only CSS. For the multi-page case, the key piece is an at-rule:
@view-transition {
navigation: auto;
}With that line, two documents from the same origin can opt in to a transition when navigating between them. It does not replace every animation library, nor does it magically turn any navigation into something perfect, but it changes the starting point: you can now get a modern app feel while keeping real pages, SSR, traditional frameworks, or static sites.
Visual demos
First, the most expressive case: a shared element travels from a card into the detail page hero.
And this is the baseline case: no shared elements, just a fade between full documents.
What problem it solves
Page transitions have two main goals: reduce the visual jump between one screen and another, and keep the user's context while navigating.
Without View Transitions, traditional navigation tends to feel like a hard cut: click, flash or sudden change, new page. That is fine for many cases, but in visual interfaces it can feel rough. Think of situations like:
- A gallery where you click a card and the image expands into the detail.
- A blog where an article's title or hero seems to continue from the list.
- A store where a product photo transforms into its page.
- A dashboard where a card grows into a detailed view.
- Documentation where content slides smoothly between sections.
Before, doing this elegantly meant controlling navigation from JavaScript. Now, for many cases, the browser can do the heavy lifting.
How it works under the hood
It is worth understanding the mental model, because almost everything else follows from it.
When a transition happens, the browser goes through these steps:
- Capture the old state. It takes a snapshot of the page as it looks right now.
- Apply the change. In an SPA, it runs your callback that mutates the DOM. In an MPA, it loads the new document.
- Capture the new state. It takes another snapshot, now with the result.
- Build a tree of pseudo-elements on top of the whole page and animates from the old snapshot to the new one.
That tree is the part worth keeping clear, because it is what you will style with CSS:
::view-transition (layer covering the whole screen)
└─ ::view-transition-group(name) (one group per name + "root")
└─ ::view-transition-image-pair(name) (holds the old/new pair)
├─ ::view-transition-old(name) (snapshot of the previous state)
└─ ::view-transition-new(name) (snapshot of the new state)The key is separating two responsibilities:
- The
groupanimates position and size. This is what makes an element appear to "fly" and grow from one place to another. - The
oldandnewdo the crossfade of the content: the old one fades out and the new one fades in.
By default, the whole document participates with an implicit name called root. Since root does not change size or position between pages, its group animates nothing visible and you only see the crossfade (the page fade). When you give a custom name to an element that does change its box between states, its group animates that box and you get the "morph" effect.
With this model in mind, the examples stop being magic recipes: you know exactly which pseudo-element you are touching and why.
Two scenarios: same-document (SPA) and cross-document (MPA)
The API has two conceptual modes.
Same-document (SPA)
The page does not reload; JavaScript updates the DOM and you wrap that change:
const transition = document.startViewTransition(() => {
// Here you mutate the DOM (insert, replace, reorder).
updateTheDOM();
});startViewTransition returns an object with several useful promises:
const transition = document.startViewTransition(updateTheDOM);
transition.updateCallbackDone; // the callback (DOM change) finished
transition.ready; // the pseudo-elements exist and the animation is about to start
transition.finished; // the animation finished
// And you can cancel the animation (not the DOM change):
// transition.skipTransition();ready is especially useful: it is the exact moment to add animations with the Web Animations API if you prefer JS over CSS.
Cross-document (MPA)
This is the most interesting case for multi-page sites, and it needs no JavaScript. You do not call startViewTransition(): the transition happens when the user navigates between two same-origin pages and both opt in.
@view-transition {
navigation: auto;
}That CSS must be available on both the origin page and the destination page. For example:
<!-- /index.html -->
<link rel="stylesheet" href="/styles.css">
<a href="/product/sneakers">View product</a><!-- /product/sneakers.html -->
<link rel="stylesheet" href="/styles.css">
<h1>Limited edition sneakers</h1>/* /styles.css */
@view-transition {
navigation: auto;
}With that you already get a default transition: usually a soft cross-fade between the previous page and the new one, if the browser supports it.
Important conditions
Cross-document transitions do not fire for just any navigation. There are rules:
- Same origin. Same scheme, host, and port. A navigation from
https://example.com/blogtohttps://example.com/aboutcan participate; one tohttps://other-domain.comcannot. - Both pages opt in. Both must have
@view-transition { navigation: auto }. - The browser may skip the transition if navigation takes too long. Chrome documents that if it exceeds a few seconds, it skips the animation so it does not block the experience.
- It is progressive enhancement. If the browser does not support the API, navigation keeps working with the traditional change.
Example 1: fade between pages
The simplest example —and the safest place to start— is a fade between the previous document and the new one. As we saw, it affects the root snapshot, so it does not require coordinating specific elements.
@view-transition {
navigation: auto;
}
::view-transition-old(root) {
animation: fade-out 180ms ease both;
}
::view-transition-new(root) {
animation: fade-in 220ms ease both;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}Example 2: directional slide
For navigation between sections, a slide can communicate the direction of the change better.
@view-transition {
navigation: auto;
}
::view-transition-old(root) {
animation: slide-out 240ms cubic-bezier(.4, 0, .2, 1) both;
}
::view-transition-new(root) {
animation: slide-in 240ms cubic-bezier(.4, 0, .2, 1) both;
}
@keyframes slide-out {
to {
opacity: 0;
transform: translateX(-24px);
}
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(24px);
}
}Use it carefully: too much movement is tiring. On content sites it should stay subtle.
Example 3: respect prefers-reduced-motion
This is mandatory for a responsible implementation. Some people prefer reduced motion for comfort, accessibility, or because it makes them dizzy.
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}Navigation still works the same; the movement simply disappears.
Example 4: shared element between list and detail
This is where the API shines. To make an element animate separately from the rest of the page, you give it a view-transition-name and use the same name on both screens. The browser understands they are "the same" element and animates its group (position and size) from one box to the other.
In the list:
<a class="post-card" href="/blog/css-view-transitions">
<img
class="post-card__image"
src="/images/view-transitions-card.jpg"
alt="Web interface with a page transition"
>
<h2>CSS View Transitions</h2>
</a>In the detail:
<article class="post">
<img
class="post__hero"
src="/images/view-transitions-card.jpg"
alt="Web interface with a page transition"
>
<h1>CSS View Transitions: page transitions</h1>
</article>You assign the same name to both elements:
.post-card__image,
.post__hero {
view-transition-name: article-hero;
}And, if you want, you customize its timing and curve:
::view-transition-old(article-hero),
::view-transition-new(article-hero) {
animation-duration: 320ms;
animation-timing-function: cubic-bezier(.2, 0, 0, 1);
}The critical point: names must be unique among the elements rendered at the same time. If two visible elements share the same view-transition-name, the browser does not know which one to animate and aborts the transition.
Example 5: lists with unique names
In a list with many cards you cannot give them all the same fixed name:
/* Bad idea if many cards are visible: it creates duplicate names */
.card-image {
view-transition-name: product-image;
}The strategy is to generate a unique name per item, usually from the id or slug:
<a class="product-card" href="/products/42">
<img
src="/products/42.jpg"
alt="Compact camera"
style="view-transition-name: product-42-image"
>
<h2 style="view-transition-name: product-42-title">Compact camera</h2>
</a>And in the detail, the same name:
<img
src="/products/42.jpg"
alt="Compact camera"
style="view-transition-name: product-42-image"
>
<h1 style="view-transition-name: product-42-title">Compact camera</h1>With any server-side template (PHP, Django, Rails, Laravel Blade) or component framework (React, Vue, Angular, Svelte), that name can be generated from the resource's id or slug.
Example 6: React and Angular
In SPAs the idea is the same as in vanilla: you wrap the state or route change with startViewTransition. Only who triggers that change differs.
In React, for a same-document interaction (for example, switching tabs):
function changeTab(nextTab: string) {
if (!document.startViewTransition) {
setTab(nextTab);
return;
}
document.startViewTransition(() => {
setTab(nextTab);
});
}In Angular (17+), the router ships built-in view transitions. You enable them when configuring the router, without writing startViewTransition by hand:
import { provideRouter, withViewTransitions } from '@angular/router';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withViewTransitions()),
],
});With that, every route change is automatically wrapped in document.startViewTransition. For changes that are not navigation, you can call the API directly, just like in vanilla JS.
The CSS is identical in both cases: you define the names and animate the pseudo-elements.
.tab-panel {
view-transition-name: active-tab-panel;
}The difference between frameworks is not in the transitions API, but in how each one updates the DOM. The CSS layer does not change.
Example 7: animating the theme change
The API also works for same-document states. A popular case is animating the switch between light and dark theme.
const button = document.querySelector('[data-theme-toggle]');
button.addEventListener('click', () => {
const updateTheme = () => {
document.documentElement.classList.toggle('dark');
};
if (!document.startViewTransition) {
updateTheme();
return;
}
document.startViewTransition(updateTheme);
});::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 250ms;
}With a bit more CSS you can create radial wipe effects, but it is best not to overdo it if the app is a productivity tool.
Example 8: excluding problematic elements
Sometimes you do not want an element to participate as a separate snapshot, or you want to avoid visual artifacts. You remove its name:
.video-player,
.live-widget,
.no-transition {
view-transition-name: none;
}This is useful with videos, maps, canvas components, third-party widgets, complex sticky elements, ads, and iframes.
The pseudo-element tree (reference)
To keep the reference handy, these are the pseudo-elements a transition exposes and what each one controls:
/* The whole page (implicit "root" snapshot) */
::view-transition-old(root) {}
::view-transition-new(root) {}
/* A named element */
::view-transition-group(article-hero) {} /* position and size */
::view-transition-image-pair(article-hero) {} /* the pair container */
::view-transition-old(article-hero) {} /* previous content */
::view-transition-new(article-hero) {} /* new content */You can also target all groups at once with the universal selector, useful for setting a global duration or curve:
::view-transition-group(*) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(.2, 0, 0, 1);
}Remember the division of labor: the group moves and resizes; the old/new does the content crossfade.
Common real-world mistakes
These are the problems that show up most when you implement this for real (not in a demo):
The navigation does a full reload and there is no transition
Cross-document transitions only happen if the browser does a "soft" same-origin navigation. If the link redirects (for example, the canonical URL has a trailing slash and your link does not, and the server returns a 301), many routers fall back to a full page reload, and the transition does not fire. Typical symptom: you see the transition in one direction but not the other. Fix: make links point to the final URL, with no intermediate redirects.
The shared element "does not fly", it only fades
If the morph feels like a simple fade, it is almost always because the group (which moves and resizes) is too short or gets masked by the root crossfade. Give the group a bit more duration and keep the page fade shorter, so attention goes to the element that travels.
The image flickers or reloads
If the card image and the detail image use different URLs (different size or crop), the browser treats them as different resources and downloads the detail one from scratch, which causes a flicker mid-transition. Use the same URL in both so the browser reuses the already-loaded, cached bitmap.
Images with loading="lazy" or outside the viewport
An element that has not painted yet (because of lazy loading or because it is off-screen) can be captured blank. Make sure the element is rendered before the transition starts.
Duplicate names
Two visible elements with the same view-transition-name abort the transition. Keep names unique per rendered element.
Browsers without support and reduced-motion
Never rely on the animation to communicate state. If there is no support or the user requested less motion, navigation must still be perfectly understandable.
How to debug transitions
Transitions are fast, so it helps to be able to see them in slow motion:
Slow them down temporarily with CSS while developing. It is the most practical technique:
::view-transition-group(*) { animation-duration: 3s !important; }Inspect the pseudo-elements. During the transition, the
::view-transition-*tree hangs off the:root. In Chrome DevTools you can see them and review their computed styles.The Animations panel in Chrome captures the transition and lets you replay it more slowly.
getAnimations()lets you list active animations by code, including those of the pseudo-elements, to confirm the transition was actually generated:document.getAnimations().forEach((a) => console.log(a.effect?.pseudoElement));
Browser support
Support has advanced, but it is best to treat it as progressive enhancement:
- Chrome and Edge have supported same-document View Transitions for several versions, and cross-document (MPA) transitions since Chrome 126.
- Safari supports same-document View Transitions in modern versions, and cross-document starting with Safari 18.2.
- Firefox lags behind: check by version, because it has been behind flags or in development.
- MDN marks
@view-transitionas limited availability, precisely because it does not behave the same across all widely used browsers.
The practical recommendation: use it without breaking normal navigation.
Feature detection
For SPAs you can detect the API before using it:
if ('startViewTransition' in document) {
document.startViewTransition(() => updateDOM());
} else {
updateDOM();
}For MPAs, the CSS opt-in needs no JavaScript. If you want to isolate styles, you can use @supports:
@supports (view-transition-name: root) {
@view-transition {
navigation: auto;
}
}Even so, an unknown @view-transition block is ignored by browsers that do not understand it, so you can usually include it directly.
Best practices
- Keep animations short. A page transition should feel like continuity, not an intro. Between 150 ms and 350 ms is usually enough.
- Do not animate everything with large displacements. A fade is boring but safe; big slides and zooms look good in demos but tire users in daily use.
- Use unique names.
view-transition-namemust be unique per visible element; for lists, generate names from IDs. - Respect accessibility. Include
prefers-reduced-motionand take care of focus, especially in SPAs. - Avoid fragile elements. Videos, iframes, canvas, maps, and complex widgets can produce odd snapshots: test them or exclude them.
- Do not rely on the transition to communicate state. Navigation must be understandable even if the animation does not happen.
When to use it and when not
It makes a lot of sense in visual blogs, portfolios, stores, galleries, documentation with side navigation, dashboards, and internal apps where spatial continuity helps.
It is not always worth it in critical forms, sensitive payment flows, interfaces where movement distracts, or very slow pages with a lot of dynamic content.
What it means for frontend
The View Transitions API matters because it shrinks the distance between a traditional MPA and the fluid feel of an SPA. And that matters because an MPA usually has clear advantages: mental simplicity, good SSR support, less client-side JavaScript, native browser navigation, and better compatibility with cache, links, and fallback.
Before, if you wanted nice transitions, you often ended up accepting more JavaScript than necessary. Now you can start with CSS and let the browser do a big part of the work.
Conclusion
CSS and the modern web platform already support page transitions. The key for MPAs is:
@view-transition {
navigation: auto;
}And for shared elements, a unique name on both screens:
.element {
view-transition-name: unique-name;
}It should still be used as progressive enhancement, but it is already practical enough for real sites, especially if your audience uses modern browsers. The interesting part is not just that pages look better: it is that the web regains a capability that used to push many teams toward heavier SPAs —visual continuity, fluid navigation, and declarative animations— without giving up the simple document model.
Official references
- MDN: View Transition API — https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API
- MDN: @view-transition — https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@view-transition
- MDN: view-transition-name — https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/view-transition-name
- Chrome Developers: Cross-document view transitions for multi-page applications — https://developer.chrome.com/docs/web-platform/view-transitions/cross-document
- Can I Use: View Transitions API — https://caniuse.com/view-transitions