Why the Web Needs a Built-In Transition System

Animations have been a core part of front-end development for well over a decade. CSS has offered transitions and keyframe animations since 2009, and the Web Animations API plus libraries like GSAP have made complex, elaborate motion possible across the web. Yet there remains a category of animation that historically required significant effort: big-picture, state-based UI transitions.

Consider a task board with multiple columns where items shift between states. Animating that kind of change cleanly requires having both the old view and the new view present in the DOM simultaneously. That alone creates maintenance and complexity problems. Then you add the performance cost: JavaScript libraries need to be downloaded and parsed before they can run, and JavaScript remains the most expensive resource on the web. A developer could be forgiven for questioning whether the animation is worth the overhead of building, maintaining, and shipping it.

The View Transitions API changes that calculus. It delegates the heavy lifting of transitioning between visual states to the browser itself, letting you write plain CSS and JavaScript for DOM updates and animation styles. You get the polished effects typically associated with JavaScript frameworks, but without the added dependencies and their associated costs. The API also hands over full control of the animation itself through standard CSS animation properties, giving you influence over each individual state of the transition.

Standards Status and Support

The View Transitions API specification has reached Candidate Recommendation Snapshot status with the W3C, a step on the formal path toward becoming a W3C Recommendation. It remains in a feedback period and is not yet ready for production use. As of this writing, support is available in up-to-date Chrome, Edge, Opera, and Android Browser versions. Safari has indicated a positive position on the API, and Firefox has an open adoption ticket, but both browsers are still pending. Until then, the API is best treated as an experimental feature.

One more historical note: the API was originally championed under a different name, the Shared Element Transitions API. Older articles published around 2021 and 2022 may still use that original term.

A Card Lightbox With the View Transitions API

To see how the View Transitions API works beyond a simple page-to-page swap, let’s build an image lightbox inside a card grid. Clicking a card’s image moves it into an overlay without navigating away. The markup starts with a <main> grid container holding styled <figure> elements.

<aside class="overlay">
  <div class="overlay__inner"></div>
</aside>

<main>
  <figure>
    <div>
      <img class="gallery__image" src="image-01.webp" alt="Vast, still lake on a sunny day." />
    </div>
    <figcaption>Peyto Lake, Canada</figcaption>
  </figure>
  
  <!-- etc. -->
</main>

You can get the full markup, styles, and scripts from the CodePen embedded below. The core mechanics are in the JavaScript: clicking a card moves the <img> into the overlay, and clicking the overlay moves it back to its original spot in the grid.

See the Pen [Image gallery v2 - 1 - starting markup [forked]](https://codepen.io/smashingmag/pen/VwRZoxV) by Adrian Bece.

See the Pen Image gallery v2 - 1 - starting markup [forked] by Adrian Bece.
const overlayWrapper = document.getElementById("js-overlay");
const overlayContent = document.getElementById("js-overlay-target");

function toggleImageView(index) {
  // Get the image element by ID.
  const image = document.getElementById(`js-gallery-image-${index}`);

  // Store image parent element.
  const imageParentElement = image.parentElement;

  // Move image node from grid to modal.
  moveImageToModal(image);

  // Create a click listener on the overlay for the active image element.
  overlayWrapper.onclick = function () {
    // Return the image to its parent element
    moveImageToGrid(imageParentElement);
  };
}

// Helper functions for moving the image around and toggling the overlay.
function moveImageToModal(image) {
  // Show the overlay
  overlayWrapper.classList.add("overlay--active");
  overlayContent.append(image);
}

function moveImageToGrid(imageParentElement) {
  imageParentElement.append(overlayContent.querySelector("img"));
  // Hide the overlay.
  overlayWrapper.classList.remove("overlay--active");
}

Note that we’re not cloning the <img>. The same DOM node is being moved between its parent in <main> and the <aside> overlay, which also toggles a visibility class.

Adding the Default Transition

Introducing the API is a simple matter of wrapping our DOM update functions inside document.startViewTransition. In toggleImageView, the two functions moveImageToModal and moveImageToGrid handle the updates, and we pass them in as a callback.

// Fallback
if (!document.startViewTransition) {
  doSomething(/*...*/);
  return;
}

// Use View Transitions API
document.startViewTransition(() => doSomething( /*...*/ ));
function toggleImageView(index) {
  const image = document.getElementById(`js-gallery-image-${index}`);

  const imageParentElement = image.parentElement;

  if (!document.startViewTransition) {
    // Fallback if View Transitions API is not supported.
    moveImageToModal(image);
  } else {
    // Start transition with the View Transitions API.
    document.startViewTransition(() => moveImageToModal(image));
  }

  // Overlay click event handler setup.
  overlayWrapper.onclick = function () {
    // Fallback if View Transitions API is not supported.
    if (!document.startViewTransition) {
      moveImageToGrid(imageParentElement);
      return;
    }
 
    // Start transition with the View Transitions API.
    document.startViewTransition(() => moveImageToGrid(imageParentElement));
  };
}

The result is an automatic cross-fade animation between the old and new states, requiring no extra CSS or animation code. This currently works only in Chrome.

See the Pen [Image gallery v2 - 2 - view transitions API [forked]](https://codepen.io/smashingmag/pen/BabBXPa) by Adrian Bece.

See the Pen Image gallery v2 - 2 - view transitions API [forked] by Adrian Bece.

Designating the Active Image

The default cross-fade applies to the entire viewport—both the heavy grid and the new overlay layout. The API doesn’t yet know that the image being moved is the same element across both states. To tell it to track that element specifically, we give it a name using the CSS view-transition-name property. This instructs the browser to capture its size and position and animate between them.

The active image gets a modifier class, .gallery__image--active, which carries a transition name such as active-image. The name must be unique across all rendered elements so the animation isn't re-applied elsewhere on the page. When the overlay closes and the image returns to the grid, we remove the class from the element so the next image can participate in its own transition.

.gallery__image--active {
  view-transition-name: active-image;
}

To ensure the state is clean, we can store the transition in a variable and await the finished attribute before toggling off the class.

// Start the transition and save its instance in a variable
const transition = document.startViewTransition(() =&gtl /* ... */);

// Wait for the transition to finish.
await transition.finished;

/* Cleanup after transition has completed */
function toggleImageView(index) {
  const image = document.getElementById(`js-gallery-image-${index}`);
  
  // Apply a CSS class that contains the view-transition-name before the animation starts.
  image.classList.add("gallery__image--active");

  const imageParentElement = image.parentElement;
  
  if (!document.startViewTransition) {
    // Fallback if View Transitions API is not supported.
    moveImageToModal(image);
  } else {
    // Start transition with the View Transitions API.
    document.startViewTransition(() => moveImageToModal(image));
  }

  // This click handler function is now async.
  overlayWrapper.onclick = async function () {
    // Fallback if View Transitions API is not supported.
    if (!document.startViewTransition) {
      moveImageToGrid(imageParentElement);
      return;
    }

    // Start transition with the View Transitions API.
    const transition = document.startViewTransition(() => moveImageToGrid(imageParentElement));
    
    // Wait for the animation to complete.
    await transition.finished;
    
    // Remove the class that contains the page-transition-tag after the animation ends.
    image.classList.remove("gallery__image--active");
  };
}

While it’s possible to toggle the view-transition-name property directly via inline HTML and JavaScript, we recommend keeping it in CSS for maintainability. This enables use of media queries, feature queries, and simpler fallbacks all in one place without cluttering markup.

// Applies view-transition-name to the image
image.style.viewTransitionName = "active-image";

// Removes view-transition-name from the image
image.style.viewTransitionName = "none";

Let’s see the result in Chrome with the transition element applied:

See the Pen [Image gallery v2 - 3 - transition element [forked]](https://codepen.io/smashingmag/pen/zYbOgmp) by Adrian Bece.

See the Pen Image gallery v2 - 3 - transition element [forked] by Adrian Bece.
The View Transitions API treats the targeted element as the same element between the states, applies special position and size animations, and crossfades everything else.
(Large preview)

Deeper Customization Through Pseudo-Elements

The API uses CSS animation properties under the hood, so duration and easing are easily customizable. However, you can’t style those directly on DOM elements. Instead, the API constructs a pseudo-element tree you can target:

::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)
  • ::view-transition: Root of the transition overlay which sits above all other content.
  • ::view-transition-group: Mirrors the size and position of the element between old and new states.
  • ::view-transition-image-pair: Isolates blending between the old and new snapshots.
  • ::view-transition-old(...) and ::view-transition-new(...): Capture the snapshots of the two states.

Names applied via view-transition-name produce correspondingly tagged pseudo-elements underneath the root. The example below shows how an active-image tag gets its own branches:

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(active-image)
   └─ ::view-transition-image-pair(active-image)
      ├─ ::view-transition-old(active-image)
      └─ ::view-transition-new(active-image)

To customize all transition animations at once, select the pseudo elements universally with *. For finer control, target elements by their transition tag name, such as active-image.

/* Apply these styles only if API is supported */
@supports (view-transition-name: none) {
  /* Cross-fade animation */
  ::view-transition-image-pair(root) {
    animation-duration: 400ms;
    animation-timing-function: ease-in-out;
  }

  /* Image size and position animation */
  ::view-transition-group(active-image) {
    animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
  }
}

See the Pen [Image gallery v2 - 4 - custom CSS [forked]](https://codepen.io/smashingmag/pen/jOJNgXM) by Adrian Bece.

See the Pen Image gallery v2 - 4 - custom CSS [forked] by Adrian Bece.

Support Detection and Fallbacks

Graceful degradation is straightforward, both in JavaScript and CSS. The earlier code included a support check which we can do in either of two ways. In JavaScript, test for the presence of startViewTransition on the document object:

// etc.

// Move the image from the grid container to the overlay.
if (!document.startViewTransition) {
  // Fallback if View Transitions API is not supported.
  moveImageToModal(image);
} else {
  // Start transition with the View Transitions API.
  document.startViewTransition(() => moveImageToModal(image));
}

// Move the image back to the grid container.
overlayWrapper.onclick = async function () {
  // Fallback if View Transitions API is not supported.
  if (!document.startViewTransition) {
    moveImageToGrid(imageParentElement);
    return;
  }
}

// etc.

Then pair that detection with CSS overrides. A JavaScript-based check can add a class to the document root, read in styles that only appear when the feature is present. This is particularly useful over the CSS-only alternative, which relies on the relatively new @supports at-rule that might not be available in older browsers.

if("startViewTransition" in document) {
  document.documentElement.classList.add("view-transitions-api");
}
// Fallback
if (!document.startViewTransition) {
  doSomething(/*...*/);
  return;
}

// Use View Transitions API (Arrow functions).
document.startViewTransition(() => doSomething(/*...*/));

The CSS alone can similarly condition styles on support, including a not keyword for the reverse scenario:

@supports (view-transition-name: none) {
  /* View Transitions API is supported */
  /* Use the View Transitions API styles */
}

@supports not (view-transition-name: none) {
  /* View Transitions API is not supported */
  /* Use a simple CSS animation if possible */
}

You can then style your UI as you normally would, and if the View Transitions API is missing, the fallback simply shows tiles without animation:

/* View Transitions API is supported */
html.view-transitions-api {}

/* View Transitions API is not supported */
html:not(.view-transitions-api) {}
An example of what can be rendered if the View Transitions API is unsupported
(Large preview)

Respecting Motion Preferences

Animations may be delightful, but users who suffer from motion sensitivity or vestibular disorders will appreciate fewer moving parts. Detect their preference at the OS level with the standard prefers-reduced-motion media query, and tone down transitions accordingly. While disabling all animations is heavy-handed, rule still accounts for a broad range of cases; reduced motion doesn’t always mean zero motion, so tailor the default just right for your audience.

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

Complete Demo

Try the finished demo with both the support check and motion preference snippet included. Play around with the easing and timing variables to find the perfect rhythm for your own pages.

See the Pen [Image gallery v2 - completed [forked]](https://codepen.io/smashingmag/pen/dyrybPL) by Adrian Bece.

See the Pen Image gallery v2 - completed [forked] by Adrian Bece.

Beyond the Default Crossfade

Once the View Transitions API's default crossfade is in place, the real creative work begins. The first example dealt with the baseline behavior: the browser takes a snapshot of the old state, swaps in the new one, and fades between them. That works for simple state changes, but it leaves motion feeling teleported rather than connected. To get more expressive transitions, we define our own CSS @keyframes and use the animation properties to tune the action.

Consider an interactive to-do list with three columns. When a user clicks an item to complete it, that item subtly scales up, leaves its original column, scales back down, and bounces as it lands in its destination container. The other items in the source column animate upward to close the gap. With just the default crossfade, you can't tell where the item went—it simply vanishes from one list and appears in another. The following demo shows that baseline:

See the Pen [To-do list v2 - 1 - crossfade [forked]](https://codepen.io/smashingmag/pen/RwdwbWb) by Adrian Bece.

See the Pen To-do list v2 - 1 - crossfade [forked] by Adrian Bece.

To make the movement readable, we need to tag the right elements and let the browser track them. In this scenario, there is one source container and two possible destinations ("Done" and "Won't Do"). The code operating the toggle is largely the same as before, but with a key detail: while a view transition runs, the browser freezes page rendering. No other element on the page can be interacted with until the animation completes. That makes short, purposeful animations a practical necessity—long delays create a sluggish feel and can drag down the Interaction to Next Paint (INP) metric.

function moveCard(isDone) {
  const card = this.window.event.target.closest("li");

  // Get the target column id (done or wont do).
  const destination = document.getElementById(
    `js-list-${isDone ? "done" : "not-done"}`
  );

  // We'll use this class to hide the item controls.
  card.classList.add("card-moving");

  if (!document.startViewTransition) {
    destination.appendChild(card);
    return;
  }

  const transition = document.startViewTransition(() => {
    // Update DOM (move the clicked card).
    destination.appendChild(card);
  });
}

Preparing the Elements

We use two groups of view-transition-name values to control which elements the browser watches:

  • card-active is applied to the item being moved. It is set right before the animation and removed when the animation ends.
  • card-${index + 1} marks leftover items in the source column. Unique indexes give the browser an ordered list of elements whose positions change when the active item departs.

That's enough for the browser to track positions and sizes, and the layout moves on its own. But while the other items fill the gap smoothly, the rigid default timing leaves room for more polish.

// Assign unique `view-transition-name` values to all task cards.
const allCards = document.querySelectorAll(".col:not(.col-complete) li");
allCards.forEach(
  (c, index) => (c.style.viewTransitionName = `card-${index + 1}`)
);

// This function is now async.
async function moveCard(isDone) {
  const card = this.window.event.target.closest("li");

   // Apply card-active to a card that has been clicked on.
   card.style.viewTransitionName = "card-active";

  const destination = document.getElementById(
    `js-list-${isDone ? "done" : "not-done"}`
  );
  
  card.classList.add("card-moving");

  if (!document.startViewTransition) {
    destination.appendChild(card);
    return;
  }

  const transition = document.startViewTransition(() => {
    destination.appendChild(card);
  });

  // Wait for the animation to complete.
  await transition.finished;

  // Cleanup after the animation is done.
  card.style.viewTransitionName = "none";
}

See the Pen [To-do list v2 - 2 - transition elements [forked]](https://codepen.io/smashingmag/pen/MWxWgKr) by Adrian Bece.

See the Pen To-do list v2 - 2 - transition elements [forked] by Adrian Bece.

Fine-Tuning with Keyframes

To add a sense of weight, we can break the animation into a sequence and delay parts of it:

  1. The clicked item scales up as if lifted from the source container, then "flies" to the destination where it lands with a bounce.
  2. The remaining items wait a beat before they shift upward to fill the vacated space.
  3. After that shift, the source container shrinks to its new height—cutting in immediately would clip the moving cards.
  4. The destination container resizes to its new height instantly, with no crossfade.

The leftover cards share unique names such as card-1, card-2, and so on. We can select them as a set with the universal selector (*) on ::view-transition-group, then give the whole group an animation-delay so they wait for the traveling card to clear the column:

/* Delay remaining card movement */
::view-transition-group(*) {
  animation-timing-function: ease-in-out;
  animation-delay: 0.1s;
  animation-duration: 0.2s;
}

For the containers themselves, we reach for the ::view-transition-old and ::view-transition-new pseudo-elements at the transition root, since those snapshots represent the source and destination states. Both get a short delay so their resizing doesn't interfere with the main motion:

/* Delay container shrinking (shrink after cards have moved) */
::view-transition-old(root),
::view-transition-new(root) {
  animation-delay: 0.2s;
  animation-duration: 0s; /* Skip the cross-fade animation, resize instantly */
}

The clicked card gets a custom duration of its own, selected through ::view-transition-group scoped to card-active:

/* Adjust movement animation duration */
::view-transition-group(card-active) {
  animation-duration: 0.4s;
  animation-delay: 0s;
}

The organic motion we want lives in its own keyframes, applied to the ::view-transition-image-pair wrapper that contains both old and new snapshots:

/* Apply custom keyframe animation to old and new state */
::view-transition-image-pair(card-active) {
  /* Bounce effect is achieved with custom cubic-bezier function */
  animation: popIn 0.5s cubic-bezier(0.7, 2.2, 0.5, 2.2);
}

/* Animation keyframes */
@keyframes popIn {
  0% {
    transform: scale(1);
  }
  40% {
    transform: scale(1.2);
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
  }
}

With those small CSS adjustments, the transition becomes whimsical and easy to follow:

See the Pen [To-do list v2 - jumping & bouncing animation - completed [forked]](https://codepen.io/smashingmag/pen/BabaBKz) by Adrian Bece.

See the Pen To-do list v2 - jumping & bouncing animation - completed [forked] by Adrian Bece.

Two Transitions in Sequence

The API's real power shows when you run staged animations. A common e-commerce interaction—adding a product to a cart—is a good test case. In this setup, clicking a button spawns a dot on the product card. That dot travels to the cart icon fixed in the page's top-right corner, and only after it arrives does the cart counter tick up to its new value.

let counter = 0;
const counterElement = document.getElementById("js-shopping-bag-counter");

async function addToCart(index) {
  const dot = createCartDot();
  const parent = this.window.event.target.closest("button");

  parent.append(dot);

  const moveTransition = document.startViewTransition(() =>
    moveDotToTarget(dot)
  );

  await moveTransition.finished;

  dot.remove();

  if (!document.startViewTransition) {
    incrementCounter();
    return;
  }

  const counterTransition = document.startViewTransition(() =>
    incrementCounter(counterElement)
  );
}

function moveDotToTarget(dot) {
  const target = document.getElementById("js-shopping-bag-target");
  target.append(dot);
}

function incrementCounter() {
  counter += 1;
  counterElement.innerText = counter;
}

function createCartDot() {
  const dot = document.createElement("div");
  dot.classList.add("product__dot");

  return dot;
}

Rather than treating the whole page as one transition, the script awaits transition.finished before it updates the counter. Two new transition names, cart-dot and cart-counter, let us target each piece independently:

async function addToCart(index) {
  /* ... */

  const moveTransition = document.startViewTransition(() =>
    moveDotToTarget(dot)
  );

  await moveTransition.finished;
  dot.remove();

  dot.style.viewTransitionName = "none";
  counterElement.style.viewTransitionName = "cart-counter";

  if (!document.startViewTransition) {
    incrementCounter();
    return;
  }

  const counterTransition = document.startViewTransition(() =>
    incrementCounter(counterElement)
  );

  await counterTransition.finished;
  counterElement.style.viewTransitionName = "none";
}

/* ... */

function createCartDot() {
  const dot = document.createElement("div");
  dot.classList.add("product__dot");
  dot.style.viewTransitionName = "cart-dot";
  return dot;
}

Two keyframes animations handle the movement. A pair called toDown and fromUp covers the vertical travel and entrance for the dot and the counter, respectively:

/* Counter fade out and moving down */
@keyframes toDown {
  from {
    transform: translateY(0);
    opacity: 1;
  }
  to {
    transform: translateY(4px);
    opacity: 0;
  }
}

/* Counter fade in and coming from top */
@keyframes fromUp {
  from {
    transform: translateY(-3px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

The dot gets a specific animation-duration and animation-timing-function, while the counter picks up a slight vertical slide on top of the fade. Both rules are wrapped in @supports so they only reach browsers that actually support view transitions—if the assistant doesn't know view-transition-name, there is no point applying related animation code:

@supports (view-transition-name: none) {
  ::view-transition-group(cart-dot) {
    animation-duration: 0.6s;
    animation-timing-function: ease-in;
  }

  ::view-transition-old(cart-counter) {
    animation: toDown 0.15s cubic-bezier(0.4, 0, 1, 1) both;
  }

  ::view-transition-new(cart-counter) {
    animation: fromUp 0.15s cubic-bezier(0, 0, 0.2, 1) 0.15s both;
  }
}

Notice that the dot itself has no explicit dimension changes in its CSS. It responds to the space available in the cart button container that hosts it, and the view transition pseudo-element on the destination side automatically adjusts its size. The API infers its travel path from that old-to-new dimension shift, which is exactly what makes these physics-smart animations cheap to produce:

Our temporary dot element responds to container dimensions, and the API detects this change in dimensions and positions and provides a smooth transition out of the box.
Our temporary dot element responds to container dimensions and the API detects this change in dimensions and positions and provides a smooth transition out of the box. (Large preview)

See the Pen [Add to cart animation v2 - completed [forked]](https://codepen.io/smashingmag/pen/dyrybpB) by Adrian Bece.

See the Pen Add to cart animation v2 - completed [forked] by Adrian Bece.

Building Responsibly

The View Transitions API collapses what used to be involved animation work into a handful of CSS lines. That doesn't mean we should ignore the rules of good motion design. Support is still limited to Chromium-based browsers at present, though the feature has support commitments in Safari (with a positive stance) and an open implementation ticket in Firefox—broad availability is not guaranteed yet.

Even where it works, the API doesn't excuse slow or endless transitions, motion for motion's sake, or ignoring prefers-reduced-motion settings. The same production checklist applies: keep durations short, align animation with meaning, and preserve access for everyone. Used with restraint, view transitions make interfaces feel considered; used without thought, they just clutter the interaction layer.

References

Smashing Editorial