Back to notes

View Transitions: animate any DOM change with one call

26 October 2026·
cssjavascriptanimationviewtransitions
Live demoOpen full screen

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, ready rejects and the transition is skipped. In Chromium I got an InvalidStateError. 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 an AbortError. If that happens before it starts animating, its ready promise rejects, and with no handler Chromium logs an uncaught "Transition was skipped" error. If it happens mid-animation, ready has already resolved and finished simply resolves early. Press Shuffle twice quickly in the demo and the log shows both cases.
  • Clicks during the animation. The ::view-transition overlay 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: none on 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, but ViewTransition.transitionRoot is still marked experimental and Chromium-only.
  • match-element. It gives every selected element an automatic unique name, so you can skip the tile-${i} step for same-document transitions. Its support is in Chrome 137, Firefox 144 and Safari 18.4.
  • Cross-document transitions. The @view-transition rule for multi-page sites isn't Baseline yet; web-features still lists it without Firefox support.

Sources