The View Transitions API animates between two states of a page. In a single-page app, wrap the DOM update in document.startViewTransition(() => update()). Between pages on the same origin, add @view-transition { navigation: auto; } to both pages. Give an element a unique view-transition-name and the browser morphs it from its old position to its new one.
| Same-document (SPA) | Cross-document (MPA) | |
|---|---|---|
| How you start it | document.startViewTransition() | @view-transition { navigation: auto; } on both pages |
| Chrome and Edge | 111 and later | 126 and later |
| Safari | 18 and later | 18.2 and later |
| Firefox | 144 and later | Not supported yet |
| Baseline | Newly available (October 2025) | Not Baseline |
Updated
Most explanations of view transitions describe the pseudo-element tree and stop. This page starts with a working demo in your browser: shuffle a grid, open a card into a detail view, and watch named elements move. Then it gives the code for each pattern, the cross-page setup, and the fallbacks for browsers without support.
Shuffle the grid or open a card. Each tile has its own view-transition-name, so the browser animates it to its new position.
Browsers without the API run the update straight away, so nothing breaks.
function updateView(update) {
if (!document.startViewTransition) {
update();
return;
}
document.startViewTransition(update);
}
button.addEventListener("click", () => {
updateView(() => {
list.classList.toggle("grid");
});
});Only one element may carry the name at a time, so set it on the clicked thumbnail just before the transition.
.thumb.is-active,
.detail-hero {
view-transition-name: hero;
}
::view-transition-group(hero) {
animation-duration: 350ms;
animation-timing-function: cubic-bezier(.2, .8, .2, 1);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}flushSync makes React commit the new DOM inside the callback.
import { useState } from "react";
import { flushSync } from "react-dom";
export function Gallery({ items }) {
const [open, setOpen] = useState(null);
const go = (next) => {
if (!document.startViewTransition) return setOpen(next);
document.startViewTransition(() => flushSync(() => setOpen(next)));
};
return open ? (
<article onClick={() => go(null)}>
<img src={open.src} alt={open.alt} style={{ viewTransitionName: "hero" }} />
</article>
) : (
<ul>
{items.map((it) => (
<li key={it.id} onClick={() => go(it)}>
<img src={it.src} alt={it.alt} />
</li>
))}
</ul>
);
}Include this in the stylesheet of every page that should take part. Same origin only.
@view-transition {
navigation: auto;
}
@keyframes slide-out { to { transform: translateX(-30%); opacity: 0; } }
@keyframes slide-in { from { transform: translateX(30%); opacity: 0; } }
::view-transition-old(root) {
animation: 250ms ease-in both slide-out;
}
::view-transition-new(root) {
animation: 250ms ease-out both slide-in;
}| Name | Kind | What it does |
|---|---|---|
| document.startViewTransition(update) | Method | Starts a same-document transition and returns a ViewTransition |
| ViewTransition.ready | Promise | Resolves when the pseudo-elements exist and animation is about to start |
| ViewTransition.finished | Promise | Resolves when the animation ends and the new state is live |
| ViewTransition.updateCallbackDone | Promise | Resolves when your update callback has finished |
| ViewTransition.skipTransition() | Method | Skips the animation; the update still runs |
| view-transition-name | CSS property | Gives an element its own snapshot to morph |
| @view-transition | CSS at-rule | Opts a page into cross-document transitions |
| ::view-transition-old() / -new() | Pseudo-elements | The outgoing and incoming snapshots |
| ::view-transition-group() | Pseudo-element | Animates size and position for one name |
| pageswap / pagereveal | Events | Let script adjust a cross-document transition |
The browser captures the current state as images, one for the page and one for every named element. It runs your update while rendering is paused, then captures the new state.
It builds a tree of pseudo-elements above the page: a group for each name, holding an old and a new image. By default the group animates size and position while the two images cross-fade, which is what creates the morph.
A name links an element in the old state to one in the new state. A product photo in a list and the large photo on its detail page can share a name such as product-42, and the browser animates one into the other.
Set the name only where it is needed. Naming an element that does not change still costs a separate snapshot.
startViewTransition also accepts an options object with an update function and a types array. A type such as forwards or backwards can then be matched in CSS with the :active-view-transition-type() selector, so going back slides the other way.
For cross-page navigations, the types descriptor inside @view-transition sets types in the same way.
It snapshots the current page, runs your update callback, then animates from the old snapshot to the live new state. It returns a ViewTransition object with ready, finished and updateCallbackDone promises and a skipTransition() method.
Put @view-transition { navigation: auto; } in the CSS of both the page you leave and the page you arrive on. The pages must share an origin, and by default you get a cross-fade. Give matching elements the same view-transition-name on both pages to morph them.
Same-document transitions work from Firefox 144. Cross-document transitions were not supported in Firefox at the time of writing, so on those browsers the navigation simply happens without animation.
Call document.startViewTransition and update state inside flushSync from react-dom, so React writes the new DOM synchronously before the browser takes the new snapshot.
Style the pseudo-elements. ::view-transition-old(name) is the outgoing snapshot and ::view-transition-new(name) the incoming one, and ::view-transition-group(name) controls size and position. Use root as the name for the whole page.
Related: CSS transitions playground, Respecting prefers-reduced-motion, Scroll animations, Framer Motion examples, Modern CSS features.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.