← Back to the blog CSS View Transitions: page transitions without turning your site into an SPA
cssfrontendweb-developmentview-transitionsuxspampa

CSS View Transitions: page transitions without turning your site into an SPA

Complete guide to the View Transitions API: transitions between MPA pages and between SPA states with CSS. Mental model, shared element, examples in vanilla, React and Angular, common real-world mistakes, and how to debug them.

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.

Shared element transition: the card image becomes the article hero.

And this is the baseline case: no shared elements, just a fade between full documents.

Root transition: one page fades out while the next document fades in.

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:

  1. Capture the old state. It takes a snapshot of the page as it looks right now.
  2. Apply the change. In an SPA, it runs your callback that mutates the DOM. In an MPA, it loads the new document.
  3. Capture the new state. It takes another snapshot, now with the result.
  4. 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 group animates position and size. This is what makes an element appear to "fly" and grow from one place to another.
  • The old and new do 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/blog to https://example.com/about can participate; one to https://other-domain.com cannot.
  • 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-transition as 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-name must be unique per visible element; for lists, generate names from IDs.
  • Respect accessibility. Include prefers-reduced-motion and 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

A COFFEE

Did any of this help?

I write all of this in my spare time, for fun. If something helped and you feel like it, buy me a coffee. No pressure — knowing it was useful is enough.

Buy me a coffee

Comments

Loading comments…