You change the DOM however you like, wrap that change in document.startViewTransition(), and the browser animates from the old state to the new one. No FLIP math, no animation library, no keeping two copies of the UI alive. Same-document view transitions have been Baseline since October 14, 2025, and the parts that make them scale (view-transition-class and transition types) joined them by January 2026.
The demo above is a bento grid where nothing is animated by hand. The grid simply gets reordered, filtered or switched to a list, and a card turns into a full panel. Turn on Slow motion to watch it, or untick View Transitions to see the same DOM changes without it.
What it is and why it matters
Animating a layout change used to mean measuring every element before and after, then faking the motion with transforms. The View Transition API flips that around. When you call startViewTransition(update), the browser takes a snapshot of the current page, runs your update callback, captures the new state, and animates between the two with a tree of pseudo-elements that sits on top of the page.
By default the whole page is one snapshot called root, and you get a cross-fade. The spec's user agent stylesheet sets it to a quarter of a second. Give an element its own view-transition-name and it gets its own group, which the browser animates from its old position and size to its new ones. That's where the "wow" comes from.
How the demo works
Everything goes through one function. This is a trimmed version of the one in the demo:
function transition(type, update) {
if (!document.startViewTransition) {
update(); // Plan B: same change, no animation
return;
}
const vt = document.startViewTransition({ update, types: [type] });
// If another transition replaces this one before it starts animating,
// ready rejects with an AbortError. Handle it or the console will complain.
vt.ready.catch(() => {});
return vt;
}
// Shuffling is just reordering DOM nodes
const shuffled = (arr) => arr.map((el) => [Math.random(), el]).sort((a, b) => a[0] - b[0]).map(([, el]) => el);
transition('shuffle', () => grid.append(...shuffled([...grid.children])));
Each card gets a unique name and a shared class:
tile.style.viewTransitionName = `tile-${i}`;
.tile { view-transition-class: tile; }
/* Don't capture the rest of the page: the toolbar updates instantly */
:root { view-transition-name: none; }
/* One rule for all twelve cards, thanks to the class */
::view-transition-group(.tile) {
animation-duration: .55s;
animation-timing-function: cubic-bezier(.2, .9, .25, 1.15);
}
Without view-transition-class you'd write a rule for tile-0, another for tile-1, and so on. A class doesn't need to be unique and doesn't create a group; it's only a styling hook for elements that already have a name.
The types array is what lets one API do several choreographies. In CSS, :active-view-transition-type() matches the root element while a transition of that type is running:
:root:active-view-transition-type(filter) {
&::view-transition-new(.tile):only-child { animation: pop-in .45s both; }
&::view-transition-old(.tile):only-child { animation: pop-out .3s both; }
}
/* pop-in and pop-out are ordinary @keyframes (scale + opacity) */
The :only-child trick comes from the spec. When a card is filtered out it only exists in the old state, so its group has an old image and no new one. Cards that come back only have a new image. That's how the demo pops cards in and out while the rest slide into place.
The card-to-panel morph doesn't need anything new. The name moves from one element to another inside the update:
transition('open', () => {
tile.style.viewTransitionName = 'none';
panel.style.viewTransitionName = name; // same name, different element
panel.hidden = false;
});
Same name in the old and new state means same group, so the browser animates the card's box into the panel's box and cross-fades the content. I also give that pair a hero class with ::view-transition-group(.hero) { z-index: 1; }. Without it, in my first version the growing panel was painted underneath the cards that come after it.
Gotchas I hit or checked
- Names must be unique among rendered elements. If two rendered elements share a
view-transition-name,readyrejects and the transition is skipped. In Chromium I got anInvalidStateError. Hidden elements (display: none) don't count, which is why filtering works. - Interrupting a transition. Calling
startViewTransition()while another is active skips the old one with anAbortError. If that happens before it starts animating, itsreadypromise rejects, and with no handler Chromium logs an uncaught "Transition was skipped" error. If it happens mid-animation,readyhas already resolved andfinishedsimply resolves early. Press Shuffle twice quickly in the demo and the log shows both cases. - Clicks during the animation. The
::view-transitionoverlay covers the page. In Chromium, a click during the animation hit<html>, not the button underneath, until I added::view-transition { pointer-events: none; }. - The old state is a picture. The old snapshot is a static image and the new one is live, so stretched text during a size change is normal. MDN's trick of setting
height: 100%on::view-transition-old()and::view-transition-new()smooths over the different aspect ratios, and the demo uses it. - Reduced motion is on you. With reduced motion emulated in Chromium, the transitions still ran. The demo sets
animation: noneon the view transition pseudo-elements inside@media (prefers-reduced-motion: reduce), so changes apply instantly.
Browser support
According to MDN's compat data, document.startViewTransition() works in Chrome 111, Firefox 144 and Safari 18. The options object with types, and :active-view-transition-type(), arrived in Chrome 125, Firefox 147 and Safari 18.2. view-transition-class is in Chrome 125, Firefox 144 and Safari 18.2. I ran the demo in Chromium 153 and WebKitGTK 2.52 with no console errors; I couldn't run Firefox in my environment. When the method doesn't exist, the demo shows a notice and applies the same changes instantly, which is exactly how the API was designed to degrade.
What to keep an eye on
- Element-scoped transitions. MDN already documents
Element.startViewTransition()for running transitions inside a subtree, butViewTransition.transitionRootis still marked experimental and Chromium-only. match-element. It gives every selected element an automatic unique name, so you can skip thetile-${i}step for same-document transitions. Its support is in Chrome 137, Firefox 144 and Safari 18.4.- Cross-document transitions. The
@view-transitionrule for multi-page sites isn't Baseline yet; web-features still lists it without Firefox support.