
The View Transitions API: Seamless Page Transitions with CSS
Tap a photo in a native mobile app and it usually grows smoothly into a full-screen view. Tap back and it shrinks into its place in the grid. On the web, the same interaction has traditionally meant a hard cut: the old page vanishes, the new one appears, and the user's eye has to find its bearings again.
Building smooth transitions between two states used to be painful. You had to keep both the old and new DOM around, measure positions, clone elements, animate them with FLIP techniques, and then clean up. Frameworks and animation libraries hid some of this, but it was always fragile.
The View Transitions API moves that work into the browser. You tell it "I'm about to change the page", make your change, and the browser snapshots the before and after states and animates between them. Styling that animation is done almost entirely in CSS. In this guide, I'll cover same-document transitions for single-page apps, cross-document transitions for multi-page sites, and the CSS you need to make them look polished.
How View Transitions Work
The mental model is simple once you see it:
- You trigger a transition.
- The browser takes a screenshot of the current state of the page (and of any specially named elements).
- Your code updates the DOM. Rendering is paused during this step so the user doesn't see a half-updated page.
- The browser captures the new state as live content.
- It builds a tree of pseudo-elements that overlay the page, containing the old snapshot and the new live view, and animates between them.
- When the animation finishes, the pseudo-elements are removed and the real, updated page is revealed.
By default, the animation is a cross-fade of the whole page. The real power comes from giving individual elements a view-transition-name, which lets the browser morph them independently, including their position and size.
Your First Same-Document Transition
Here's a basic list and detail toggle in a single-page app:
<main class="app">
<ul class="gallery" id="gallery">
<li>
<button class="thumb" data-id="harbour">
<img src="/img/harbour.jpg" alt="Harbour" />
</button>
</li>
<li>
<button class="thumb" data-id="dunes">
<img src="/img/dunes.jpg" alt="Dunes" />
</button>
</li>
</ul>
<section class="detail" id="detail" hidden></section>
</main>
The JavaScript wraps the DOM update in document.startViewTransition():
function showDetail(id) {
const update = () => {
document.getElementById("gallery").hidden = true;
const detail = document.getElementById("detail");
detail.innerHTML = `<img src="/img/${id}.jpg" alt="" class="detail__image" />`;
detail.hidden = false;
};
if (!document.startViewTransition) {
update();
return;
}
document.startViewTransition(update);
}
document.querySelectorAll(".thumb").forEach((button) => {
button.addEventListener("click", () => showDetail(button.dataset.id));
});
That's all that's required for a working transition. The feature check keeps the app functional in browsers without support, where the update simply happens instantly.
Without any CSS, you get a smooth cross-fade of the entire page. That alone is often a noticeable improvement over a hard cut.
The Pseudo-Element Tree
To customise the animation, you need to know what the browser builds. During a transition, this structure sits on top of the page:
::view-transition
└─ ::view-transition-group(root)
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root)
└─ ::view-transition-new(root)
::view-transitionis the overlay that holds everything.::view-transition-group(name)animates size and position from the old to the new state.::view-transition-image-pair(name)holds the two images and isolates their blending.::view-transition-old(name)is the static screenshot of the old state.::view-transition-new(name)is the live representation of the new state.
By default there's one group, named root, which represents the whole document. Each element you give a view-transition-name gets its own group.
Customising the Default Animation
Changing the root transition is plain CSS animation work. Here's a slide instead of a cross-fade:
@keyframes slide-out {
to {
opacity: 0;
transform: translateX(-3rem);
}
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(3rem);
}
}
::view-transition-old(root) {
animation: 250ms ease-in both slide-out;
}
::view-transition-new(root) {
animation: 300ms ease-out both slide-in;
}
You can also slow everything down while developing, which makes it much easier to see what's happening:
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 2s;
}
The * selector matches every named group. Remove this before you ship.
Morphing Individual Elements
The "photo grows into the detail view" effect comes from giving the thumbnail and the detail image the same view-transition-name. The name must be unique on the page at any moment, so assign it only to the element being transitioned.
function showDetail(id, thumbImg) {
thumbImg.style.viewTransitionName = "hero-photo";
const transition = document.startViewTransition(() => {
thumbImg.style.viewTransitionName = "";
document.getElementById("gallery").hidden = true;
const detail = document.getElementById("detail");
detail.innerHTML = `<img src="/img/${id}.jpg" alt="" class="detail__image" />`;
detail.querySelector("img").style.viewTransitionName = "hero-photo";
detail.hidden = false;
});
return transition.finished;
}
Before the update runs, the thumbnail carries the name, so it's captured as the old state of hero-photo. Inside the update, the name moves to the detail image, which becomes the new state. The browser sees two states with the same name and animates the group's position and size between them.
If you're working with fixed elements that always exist, like a site header, you can give them a name directly in CSS:
.site-header {
view-transition-name: site-header;
}
::view-transition-group(site-header) {
animation-duration: 0s;
}
Giving the header its own name and a zero-duration group keeps it perfectly still while the rest of the page transitions underneath, which looks much more natural than having the header cross-fade with everything else.
Fixing Aspect Ratio Distortion
When a thumbnail with a square crop morphs into a wide detail image, the default behaviour stretches the snapshots, because both are sized to fill the group. A common fix is to let them keep their own aspect ratio and clip them:
::view-transition-old(hero-photo),
::view-transition-new(hero-photo) {
height: 100%;
width: auto;
object-fit: cover;
overflow: clip;
}
::view-transition-image-pair(hero-photo) {
overflow: clip;
border-radius: 0.75rem;
}
The snapshots behave like replaced elements, so object-fit works on them. The exact treatment depends on your images, so experiment with slow durations until it looks right.
Cross-Document View Transitions
Same-document transitions require JavaScript. For traditional multi-page sites, like a blog or a documentation site, there's an even simpler option: cross-document view transitions, which run automatically on same-origin navigations.
Opt in on both the old and new page:
@view-transition {
navigation: auto;
}
That's it. With that rule in your global stylesheet, clicking a link to another page on the same origin gets the default cross-fade, with no JavaScript at all. Everything you learned about pseudo-elements and view-transition-name still applies, so you can name a post's featured image on the listing page and on the article page to make it morph between them:
/* On the blog index */
.post-card--featured img {
view-transition-name: featured-image;
}
/* On the article page */
.article__hero img {
view-transition-name: featured-image;
}
For listing pages with many cards, each card needs a unique name. You can set them inline in your templates, for example style="view-transition-name: post-42", and use the same name on the matching article page.
A few things to know about cross-document transitions:
- Both pages must be same-origin, and both must opt in.
- They apply to regular navigations like clicking a link, submitting a form, or going back and forward. They don't apply to navigations that the user initiates from the browser UI, such as typing in the address bar.
- If the new page takes too long to render, the browser may skip the transition.
- The
pageswapandpagerevealevents let you customise transitions with JavaScript on the outgoing and incoming pages, for example to set names conditionally based on the URL.
Directional Transitions with Types
Forward and backward navigations often need different animations. View transition types let you label a transition and style it conditionally.
For same-document transitions, pass types when starting:
document.startViewTransition({
update: () => goToStep(next),
types: [next > current ? "forward" : "backward"],
});
Then target them in CSS with the :active-view-transition-type() pseudo-class. The slide-out and slide-in keyframes are the ones defined earlier; the reverse versions simply flip the direction:
@keyframes slide-out-reverse {
to {
opacity: 0;
transform: translateX(3rem);
}
}
@keyframes slide-in-reverse {
from {
opacity: 0;
transform: translateX(-3rem);
}
}
html:active-view-transition-type(forward) {
&::view-transition-old(root) {
animation-name: slide-out;
}
&::view-transition-new(root) {
animation-name: slide-in;
}
}
html:active-view-transition-type(backward) {
&::view-transition-old(root) {
animation-name: slide-out-reverse;
}
&::view-transition-new(root) {
animation-name: slide-in-reverse;
}
}
For cross-document transitions, you can declare types in the at-rule, or add them dynamically in pageswap and pagereveal handlers via the event's viewTransition.types set. Type support arrived later than the core API, so check support before relying on it.
Accessibility and Reduced Motion
Large sliding and zooming motions can be uncomfortable for some users. Respect their preference by reducing or removing the animations:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
With no animation, the transition resolves instantly, which is the same experience as a browser without support.
Also remember that while a transition runs, the pseudo-element overlay sits on top of the page and intercepts pointer input, so clicks don't reach the elements underneath until it finishes. That's another reason to keep transitions short, in the 200 to 400 millisecond range, so they feel responsive rather than decorative.
Common Pitfalls
Duplicate Names
If two elements have the same view-transition-name in the same state, the transition is skipped entirely, and an error is logged in the console. This is easy to hit in lists. Assign names only to the elements that need them, or generate unique names per item.
Forgetting the Callback Is Async-Friendly
The update callback can return a promise, and the browser waits for it before capturing the new state. If you're fetching data, do it inside the callback and return the promise. Just keep it fast, because rendering is paused while it runs.
document.startViewTransition(async () => {
const html = await fetch(`/partials/post/${id}`).then((r) => r.text());
document.querySelector("#content").innerHTML = html;
});
Transitioning Elements That Change Shape Drastically
Morphing a small circle avatar into a big rectangular banner can look odd because both snapshots are scaled into the same box. Either adjust with object-fit as shown earlier, or fade those elements separately instead of sharing a name.
Content That Isn't Rendered
Elements with display: none or content-visibility: hidden aren't captured. If an element doesn't participate in the transition, check that it is actually rendered in the relevant state.
Browser Support
Same-document view transitions have been available in Chromium-based browsers for several years, Safari added support in version 18, and Firefox has followed with its own implementation more recently. Cross-document transitions via @view-transition are supported in Chromium-based browsers and recent Safari, with other engines still catching up. Newer additions like view transition types and view-transition-class landed later still, so check caniuse for the specific feature you plan to use.
The good news is that view transitions degrade perfectly. The feature check in JavaScript and the at-rule in CSS are simply ignored in unsupported browsers, which get the instant update they always had. There's no layout risk in shipping them today.
Conclusion
The View Transitions API turns what used to be a complex animation problem into a small amount of CSS. Wrap state changes in document.startViewTransition() for single-page apps, or add @view-transition with navigation: auto for multi-page sites, and the browser handles the snapshots and the choreography. From there, view-transition-name lets specific elements morph between states, the pseudo-element tree gives you full control over the animations, and types let you vary the effect by direction.
Start with the default cross-fade, name one hero element, and slow the durations right down while you tune. Keep motion short, honour prefers-reduced-motion, and you'll give your site the kind of continuity users expect from native apps without adding a single animation library.


