Skip to content

View transitions without a framework

Animating between two states used to mean keeping both in the DOM and choreographing them by hand. The browser will now do the hard part for you, in about six lines.

3 min read
Contents

Animating a change of state has always been backwards. You want to say “it looked like that, now it looks like this, please make the difference smooth”. Instead you had to keep both states alive, measure them, position one on top of the other, animate, then clean up. The FLIP technique was clever precisely because the platform gave us nothing.

The View Transitions API inverts it. You change the DOM however you like, and the browser handles the rest.

The minimum

function update() {
  list.replaceChildren(...newItems);  // whatever your change is
}

if (document.startViewTransition) {
  document.startViewTransition(update);
} else {
  update();                            // older browsers: no animation, no bug
}

The browser screenshots the page, runs your callback, screenshots again, and cross-fades between them. You get a default fade for free, and the feature-detect gives you a clean fallback for nothing.

Already that beats most of the transition code I have written by hand, and it is four lines.

Making specific things move

A cross-fade is fine. What you usually want is for one element to travel — a thumbnail growing into a hero image, a row expanding into a panel.

Give the element a view transition name, and make sure the same name exists before and after:

.hero-image {
  view-transition-name: hero;
}

Now the browser animates that element between its old and new position and size, independently of the fade happening around it. It works across completely different DOM structures, which is the part that feels like cheating.

For lists, that means setting it dynamically:

function openItem(el, id) {
  el.style.viewTransitionName = 'item';
  const transition = document.startViewTransition(() => render(id));
  transition.finished.finally(() => {
    el.style.viewTransitionName = '';
  });
}

view-transition-name also accepts match-element in newer engines, which assigns a unique name per element automatically — worth checking whether you can use it, because it removes this bookkeeping entirely.

Styling the animation

The transition builds a tree of pseudo-elements outside your DOM, and you animate those with ordinary CSS:

::view-transition-old(root) {
  animation: 180ms ease-out both fade-out;
}
::view-transition-new(root) {
  animation: 260ms ease-in both fade-in;
}

::view-transition-group(hero) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

old is the outgoing snapshot, new is the incoming one, group is the container that moves and resizes between them. Name in the brackets, root for the whole page.

Two practical notes. Keep durations short — 200 to 350ms. A page transition that takes half a second feels like a slow site, not a polished one. And respect the user:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Across pages, not just within one

The genuinely surprising part is that this works on ordinary multi-page sites. Two lines of CSS, and a plain server-rendered site gets transitions between documents:

@view-transition {
  navigation: auto;
}

No router. No client-side navigation. No hydration. Astro, Rails, Laravel, a folder of HTML files — they all get it. The two pages need to be same-origin, and elements you want to travel need matching view-transition-name values on both.

This is the feature that quietly removes one of the last arguments for building a single-page app. A lot of SPAs exist because somebody wanted the page not to flash white.

Where I actually use it

Not everywhere. Transitions that do not communicate something are just latency you added on purpose.

The cases that consistently earn it:

  • Thumbnail to detail. The image travels, so the user knows exactly which thing they opened.
  • List reordering. Sort a table and watch rows slide to their new positions. Enormous clarity, near-zero code.
  • Adding or removing items. Things that appear and vanish instantly are hard to track; 200ms fixes it.
  • Navigation between related pages. Blog index to blog post, with the title carrying over.

And the cases where I skip it: anything the user does dozens of times a minute, anything under 100ms already, and modals — which have their own patterns and do not need this.

Support is good in Chromium and Safari, and Firefox has been working through it; because the feature detect is a single if, shipping it today costs you exactly nothing on engines that do not have it yet. That is about as easy a progressive enhancement decision as the web ever offers.

Share