From Element Swaps to Full View Transitions

The View Transitions API can do far more than move a single element between two states. We can use the same underlying mechanism to transition between entire views in a single-page app or complete pages in a multi-page site. The result is an app-like feel that previously required a JavaScript framework or native mobile development.

The API handles the heavy lifting. When a transition starts, the browser captures a snapshot of the current view, the DOM update runs, and a snapshot of the new view is taken. What you see animating is not actual DOM content but a CSS replacement, similar to how images and iframes are rendered. This keeps the transition free of accessibility and interaction issues that could arise from animating live elements.

const transition = document.startViewTransition(() => {
  /* Take screenshot of an outgoing state */
  /* Update the DOM - move the item from one container to another */
   destination.appendChild(card);
  /* Capture the live state and perform a crossfade */
});

The default behavior is a simple crossfade. To get more interesting motion, we tell the API which elements to watch by giving them a view-transition-name. The name must be unique per page, much like an id. Once assigned, the element becomes a transition element, and the browser tracks its position and dimensions across the state change while all other content crossfades.

You can apply the name in CSS:

.active-item {
  view-transition-name: active-item;
}

Or set it directly in JavaScript:

activeItem.style.viewTransitionName = "active-item";

With a name applied, we get access to a set of pseudo-elements for fine-grained animation control. We can target the whole transition group with ::view-transition-group(*), the outgoing and incoming root snapshots with ::view-transition-old(root) and ::view-transition-new(root), or a specific watched element such as ::view-transition-image-pair(card-active). This allows us to define custom @keyframes with distinct timing functions, delays, and durations for each part.

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

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

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

/* Animation keyframes */
@keyframes popIn { /* ... */ }

Support remains a constraint. The API is currently available in Chrome, Edge, Opera, and Android Browser. Safari has indicated a positive position, and Firefox has an open implementation ticket, but neither has shipped support yet. Multi-page transitions are even further out: only Chrome Canary supports them at the moment, behind a flag. In Canary, you can enable the feature by navigating to the following:

chrome://flags/#view-transition-on-navigation

Native Page Transitions In Multi-Page Apps

Multi-page applications give us the most direct path to full view transitions. The baseline implementation requires almost no JavaScript at all. We'll build a static Eleventy site and wire up transitions between pages, then layer in custom animations on specific elements.

Keep in mind that MPA view transitions currently require Chrome Canary with the view-transition-on-navigation feature flag enabled. The final API may shift before wide adoption, but the core concepts will hold.

The Markup We Start With

After Eleventy compiles our templates with Markdown data, each card link produces the following structure:

!-- Item grid element on the listing page (homepage) -->
<a href="https://www.smashingmagazine.com/some-path" class="card">
  <figure class="card__figure">
    <picture>
      <!-- Prefer AVIF images -->
      <source type="image/avif">
        <!-- JPG or PNG fallback -->
      <img class="card__image" src="..." width="600" height="600">
    </picture>
    <figcaption class="card__content">
      <h2 class="card__title">Reign Of The Reaper</h2>
      <h3 class="card__subtitle">Sorcerer</h3>
    </figcaption>
  </figure>
</a>

The key piece is an anchor with a .card class wrapping a <figure> that contains a <picture> with an image and a <figcaption> carrying the card's text content. Clicking the card navigates to the linked page, which is where the transition kicks in.

Enabling The Default Crossfade

Getting a crossfade between pages in an MPA is as simple as adding one line to the document <head> alongside your other meta tags:

<meta name="view-transition" content="same-origin" />

The browser handles all the orchestration from there. There is no need to call document.startViewTransition for page-level navigation; that API remains useful for transitions within a single page, which we covered in the previous article. For MPA navigation, this meta tag is sufficient.

Defining The Elements That Move

Our site has two clear navigation flows: from the homepage to an item details page and back, and between two different item details pages. In each case, we want the header and the card image to participate in the transition rather than just crossfading with everything else.

Diagram of the page and element transitions.
(Large preview)
Diagramming the transition between two product pages.
(Large preview)

The header and card component are the two transition elements at play. We'll configure them one at a time.

Handling The Header

Let's look at the header markup first:

<div class="header__wrapper">
  <!-- Link back arrow -->
  <a class="header__link header__link--dynamic" href="/">
    <svg ...><!-- ... --></svg>
  </a>
  <!-- Page title -->
  <h1 class="header__title">
    <a href="/" class="header__link-logo">
      <span class="header__logo--deco">Vinyl</span>Emporium </a>
  </h1>
  <!-- ... -->
</div>

When a user navigates from the homepage to a product page, the arrow icon appears while the title shifts right. Returning to the homepage reverses that. We handle visibility with display: none:

/* Hide back arrow on the homepage */
.home .header__link--dynamic {
    display: none;
}

Two elements in the header get transition names: the back arrow (.header__link--dynamic) and the title (.header__title). With the View Transitions API, these are registered using the view-transition-name CSS property:

@supports (view-transition-name: none) {
  .header__link--dynamic {
    view-transition-name: header-link;
  }
  .header__title {
    view-transition-name: header-title;
  }
}

Note that the whole block sits inside a CSS @supports query, so unsupported browsers are unaffected.

Registering The Card As A Transition Element

Every view transition on a page must have unique names. Since multiple cards exist on the homepage, we shouldn't assign a fixed view-transition-name to all of them upfront. Instead, we assign the name only to the card that gets clicked.

There are several ways to detect which card was clicked. For this demo, the Navigation API is a good fit because it can intercept navigation events and track back/forward direction. We use it to find the image whose link matches the target URL and give that image its transition name:

// Utility function for applying view-transition-name to clicked element
function applyTag(url) {
  // Select an image in a link matching the link that has been clicked on.
  const image = document.querySelector(
    `a[href="${url.pathname}"] .card__image`
  );
  if (!image) return;
  image.style.viewTransitionName = "product-image";
}

// Intercept the navigation event.
navigation.addEventListener("navigate", (event) => {
  const toUrl = new URL(event.destination.url);

  // Return if origins do not match or if API is not supported.
  if (!document.startViewTransition || location.origin !== toUrl.origin) {
    return;
  }
  applyTag(toUrl);
});

On the destination item details page, the image is always the same element, so we can assign its transition name directly in CSS:

<section class="product__media-wrapper" style="--cover-background-color: #fe917d">
  <nav class="product__nav">
    <span>
      <a class="product__link product__link--prev" href="...">
        <svg ... ><!-- ... --></svg>
      </a>
    </span>
    <span>
      <a class="product__link product__link--next" href="...">
        <svg ... ><!-- ... --></svg>
      </a>
    </span>
  </nav>
  <article class="product__media">
    <div class="product__image">
      <!-- LP sleeve cover image -->
      <picture>
        <source type="image/avif">
        <img src="..." width="600" height="600">
      </picture>
    </div>
    <div class="product__image--deco">
      <!-- LP image -->
      <picture>
        <source type="image/avif">
        <img src="..." width="600" height="600">
      </picture>
    </div>
  </article>
</section>

Once the names are in place, we can tune the animations with standard CSS animation properties like duration and easing:

@supports (view-transition-name: none) {
  .product__image {
    view-transition-name: product-image;
  }
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-timing-function: ease-in-out;
    animation-duration: 0.25s;
  }
  ::view-transition-group(product-image) {
    animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
    animation-duration: 0.4s;
  }
}

That completes a fairly polished page transition with just a handful of declarations.

Animating Beyond The Basics

For more interesting effects, we need @keyframes that run after the page swap. Keeping these animations short matters because the page is not interactive while a view transition runs. Any delay is felt by the user, so we aim for concise but expressive motion.

We'll define two keyframe sets: one to open the album cover (open) and one to make the record slide out of the sleeve (rollOut):

/* LP gatefold sleeve open animation and styles */
.product__media::before {
  /* Hide until animatton begins (avoid z-index issues) */
  opacity: 0;
  /* ... */
  animation: open 0.25s 0.45s ease-out forwards;
}

/* LP roll out animation and styles */
.product__image--deco {
  /* Hide until animatton begins (avoid z-index issues) */
  opacity: 0;
  /* ... */
  animation: rollOut 0.6s 0.45s ease-out forwards;
}

@keyframes open {
  from {
    opacity: 1;
    transform: rotateZ(0);
  }
  to {
    opacity: 1;
    transform: rotateZ(-1.7deg);
  }
}

@keyframes rollOut {
  from {
    opacity: 1;
    transform: translateX(0) translateY(-50%) rotateZ(-45deg);
  }
  to {
    opacity: 1;
    transform: translateX(55%) translateY(-50%) rotateZ(18deg);
  }
}

These exist in CSS only at this point. We need to apply them before they'll appear in the page transition.

Complex Transitions Between Product Pages

Now we can handle navigation between two product details pages. Clicking the left or right arrows switches to adjacent products; the product image and the disc behind it are the elements that should move during that transition.

Diagramming the transition between product pages.
Notice how we’re now using View Transitions API to reverse the CSS eye-candy animation. (Large preview)

We start by assigning transition names to the product image (.product__image--deco) and the disc behind it (.product__media::before):

@supports (view-transition-name: none) {
  .product__image--deco {
    view-transition-name: product-lp;
  }
 .product__media::before {
    view-transition-name: flap;
  }
  ::view-transition-group(product-lp) {
    animation-duration: 0.25s;
    animation-timing-function: ease-in;
  }
  ::view-transition-old(product-lp),
  ::view-transition-new(product-lp) {
    /* Removed the crossfade animation */
    mix-blend-mode: normal;
    animation: none;
  }
}

The disc's crossfade must be disabled on both the old and new view states (::view-transition-old(product-lp) and ::view-transition-new(product-lp)) so it swaps instantly rather than fading. Without that adjustment, navigating back from a product page to the homepage leaves the disc visible until the transition finishes, which looks wrong.

The fix is to conditionally remove the transition name. When a user navigates back to the homepage, we intercept the Navigation API event and set the disc's view-transition-name to none, restoring the default crossfade:

function removeTag() {
  const image = document.querySelector(`.product__image--deco`);
  image.style.viewTransitionName = "none";
}

navigation.addEventListener("navigate", (event) => {
  const toUrl = new URL(event.destination.url);

  if (!document.startViewTransition || location.origin !== toUrl.origin) {
    return;
  }

  // Remove view-transition-name from the LP if navigating to the homepage.
  if (toUrl.pathname === "/") {
    removeTag();
  }
});

With that guard in place, every navigation path resolves correctly. The homepage-to-product transition runs with the card image as the focal point, and the product-to-product transition animates the cover and disc independently. All of this is achieved with a single meta tag for the default crossfade, a couple of view-transition-name assignments, and CSS animation control.

Try The Demo

The snippets above highlight the bits most relevant to the View Transitions API. The complete source is available in the GitHub repository, and a working example is hosted here:

SPA View Transitions With React Router

Applying the View Transitions API inside a single-page application introduces a layer of complexity that multi-page setups avoid. Since all rendering is handled by JavaScript, we must rely on document.startViewTransition for any transition. Routing libraries have started to build on this — react-router, for instance, offers page transitions via the View Transitions API as an opt-in feature.

This tutorial demonstrates three distinct transition types in a React SPA for a "Museum of Digital Wonders":

  • Transitions between category pages.
  • Transitions between a category page and a product details page.
  • Transitions of a product image from its details view to an expanded state.

Setting Up The Router

We begin by establishing the routing structure. The application includes a homepage for one category, separate pages for additional categories, and dedicated detail pages for each product.

Diagramming the app’s routes.
(Large preview)

Each route is paired with a loader function that manages page data.

import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Category, { loader as categoryLoader } from "./pages/Category";
import Details, { loader as detailsLoader } from "./pages/Details";
import Layout from "./components/Layout";

/* Other imports */

const router = createBrowserRouter([
  {
    /* Shared layout for all routes */
    element: <Layout />,
    children: [
      {
        /* Homepage is going to load a default (first) category */
        path: "/",
        element: <Category />,
        loader: categoryLoader,
      },
      {
      /* Other categories */
        path: "/:category",
        element: <Category />,
        loader: categoryLoader,
      },
      {
        /* Item details page */
        path: "/:category/product/:slug",
        element: <Details />,
        loader: detailsLoader,
      },
    ],
  },
]);

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

This configuration yields three route types:

  • Homepage (/)
  • Category page (/:category)
  • Product details (/:category/product/:slug)

Based on the active route, a Layout component renders the appropriate view. With routing in place, we can address the first transition: moving between category pages.

Crossfading Between Category Pages

The default view transition is a crossfade between the outgoing and incoming views. The only page element that should not fade is the active indicator in the category navigation — a visual marker that should slide from the current filter to the next. This element needs to be registered as a transition element.

Diagramming the UI transition when navigating between category views.
The app navigation is a group of category filters where the active category is indicated by a border that transitions when another category is selected. (Large preview)

Because we are using react-router, the react-router-dom package is available, providing DOM bindings and navigation components. This package also includes an implementation of the View Transitions API. Specifically, the Link component accepts an unstable_viewTransition prop, which directs the router to execute a view transition on navigation.

import { Link, useLocation } from "react-router-dom";
/* Other imports */

const NavLink = ({ slug, title, id }) => {
  const { pathname } = useLocation();
  /* Check if the current nav link is active */
  const isMatch = slug === "/" ? pathname === "/" : pathname.includes(slug);
  return (
    <li key={id}>
      <Link
        className={isMatch ? "nav__link nav__link--current" : "nav__link"}
        to={slug}
        unstable_viewTransition
      >
        {title}
      </Link>
    </li>
  );
};

const Nav = () => {
  return 
    <nav className={"nav"}>
      <ul className="nav__list">
        {categories.items.map((item) => (
          <NavLink {...item} />
        ))}
      </ul>
    </nav>
  );
};

That prop is enough to register and run the default crossfade. The library abstracts the heavy lifting of applying transitions to bound elements and views.

Registering The Active Indicator

The navigation's active-state indicator is the sole element requiring a custom transition name. Rather than a border, we render a standard HTML horizontal rule (<hr>) conditionally, depending on the route. When a view transition starts, the <hr> is removed from the DOM and re-inserted under whichever NavLink represents the current destination route.

To ensure this transition only runs when the navigation is visible, we use react-intersection-observer to check visibility. If the navigation is on-screen, we assign the element a viewTransitionName via an inline style.

import { useInView } from "react-intersection-observer";
/* Other imports */

const NavLink = ({ slug, title, id }) => {
  const { pathname } = useLocation();
  const isMatch = slug === "/" ? pathname === "/" : pathname.includes(slug);
  return (
    <li key={id}>
      <Link
        ref={ref}
        className={isMatch ? "nav__link nav__link--current" : "nav__link"}
        to={slug}
        unstable_viewTransition
      >
        {title}
      </Link>
      {isMatch && (
        <hr
          style={{
            viewTransitionName: inView ? "marker" : "",
          }}
          className="nav__marker"
        />
      )}
    </li>
  );
};

Transitions Between Product Views

Next, we register the main product image as a transition element so it animates when navigating from one product to another. When the user navigates from a product view back to the category page, we need to fall back to a standard crossfade for the rest of the UI.

Diagramming transitioning between two product views
(Large preview)

The Card component used in category views benefits from the unstable_useViewTransitionState hook. This hook accepts a URL and returns true when an active View Transitions API transition targets that URL. This lets us conditionally assign a transition name to the card's image, ensuring it participates when navigating between a category and a product.

import { Link, unstable_useViewTransitionState } from "react-router-dom";
/* Other imports */

const Card = ({ author, category, slug, id, title }) => {
  /* We'll use the same URL value for the Link and the hook */
  const url = `/${category}/product/${slug}`;

  /* Check if the transition is running for the item details pageURL */
  const isTransitioning = unstable_useViewTransitionState(url);

  return (
    <li className="card">
      <Link unstable_viewTransition to={url} className="card__link">
        <figure className="card__figure">
          <img
            className="card__image"
            style=}}
              /* Apply the viewTransitionName if the card has been clicked on */
              viewTransitionName: isTransitioning ? "item-image" : "",
            }}
            src={`/assets/${category}/${id}-min.jpg`}
            alt=""
          />
         {/* ... */}
        </figure>
        <div className="card__deco" />
      </Link>
    </li>
  );
};

export default Card;

In the product details view, we know the main image is the transition target, so we can apply the viewTransitionName directly to that element.

import {
  Link,
  useLoaderData,
  unstable_useViewTransitionState,
} from "react-router-dom";
/* Other imports */

const Details = () => {
  const data = useLoaderData();
  const { id, category, title, author } = data;
  return (
    <>
      <section className="item">
        {/* ... */}
        <article className="item__layout">
          <div>
              <img
                style={{viewTransitionName: "item-image"}}
                className="item__image"
                src={`/assets/${category}/${id}-min.jpg`}
                alt=""
              />
          </div>
          {/* ... */}
        </article>
      </section>
    </>
  );
};

export default Details;

Two issues remain before tackling the final transition.

First, the Card's image (.card__image) is styled with a fixed aspect ratio and centering. When that same image becomes the .item-image on the product page, it should revert to its natural state. The transition element cannot detect those inherited CSS styles on its own.


/* Card component image */
.card__image {
  object-fit: cover;
  object-position: 50% 50%;
  aspect-ratio: 1;
  /* ... */
}

/* Product view image */
.item__image {
 /* No aspect-ratio applied */
 /* ... */
}

We need to customize the pseudo-elements of the transition to handle the aspect ratio change gracefully. The following snippet adapts a solution courtesy of Jake Archibald for our specific case.

/* This is same as in the Jake Archibald's snippet */
::view-transition-old(item-image),
::view-transition-new(item-image) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
  /* Make the height the same as the group,
  meaning the view size might not match its aspect-ratio. */
  height: 100%;
  /* Clip any overflow of the view */
  overflow: clip;
}

/* Transition from item details page to category page */
.category::view-transition-old(item-image) {
  object-fit: cover;
}
.category::view-transition-new(item-image) {
  object-fit: contain;
}
/* Transition from category page to item details page */
.details::view-transition-old(item-image) {
  object-fit: contain;
}
.details::view-transition-new(item-image) {
  object-fit: cover;
}

Second, we use unstable_useViewTransitionState again to give the product image a transition name only when the user is navigating back to the category page.

import {
  Link,
  useLoaderData,
  unstable_useViewTransitionState,
} from "react-router-dom";

/* Other imports */

const Details = () => {
  const data = useLoaderData();
  const { id, category, title, author } = data;
  const categoryUrl = `/${category}`;
  const isTransitioning = unstable_useViewTransitionState(categoryUrl);
  return (
    <>
      <section className="item">
        { /* ... */ }
        <article className="item__layout">
          <div>
            <img
              style={{
                viewTransitionName: isTransitioning ? "item-image" : "",
              }}
              className="item__image"
              src={`/assets/${category}/${id}-min.jpg`}
              alt=""
            />
          </div>
          {/* ... */}
        </article>
      </section>
    </>
  );
};

export default Details;

Animating Between Image States

The final transition is between two states of the same image: its default state and an expanded overlay view. This reuses the pattern from the previous article's image gallery, where an element transitions between its old and new snapshots. The concept remains identical, but here it is implemented within React components.

An image transitioning from a default state to a new, larger state.
(Large preview)

To make this work, Jake recommends leveraging React's flushSync function, which forces synchronous DOM updates inside its callback. While it is meant for sparing use, it is appropriate here to coordinate the state change with the start of the view transition.

// Assigns view-transition-name to the image before transition runs
const [isImageTransition, setIsImageTransition] = React.useState(false);

// Applies fixed-positioning and full-width image styles as transition runs
const [isFullImage, setIsFullImage] = React.useState(false);

/* ... */

// State update function, which triggers the DOM update we want to animate
const toggleImageState = () => setIsFullImage((state) => !state);

// Click handler function - toggles both states.
const handleZoom = async () => {
  // Run API only if available.
  if (document.startViewTransition) {
    // Set image as a transition element.
    setIsImageTransition(true);
    const transition = document.startViewTransition(() => {
      // Apply DOM updates and force immediate re-render while.
      // View Transitions API is running.
      flushSync(toggleImageState);
    });
    await transition.finished;
    // Cleanup
    setIsImageTransition(false);
  } else {
    // Fallback 
    toggleImageState();
  }
};

/* ... */

With that in place, the remainder is toggling class names and viewTransitionName values based on the current UI state.

import React from "react";
import { flushSync } from "react-dom";

/* Other imports */

const Details = () => {
  /* React state, click handlers, util functions... */

  return (
    <>
      <section className="item">
        {/* ... */}
        <article className="item__layout">
          <div>
            <button onClick={handleZoom} className="item__toggle">
              <img
                style={{
                  viewTransitionName:
                    isTransitioning || isImageTransition ? "item-image" : "",
                }}
                className={
                  isFullImage
                    ? "item__image item__image--active"
                    : "item__image"
                }
                src={`/assets/${category}/${id}-min.jpg`}
                alt=""
              />
            </button>
          </div>
          {/* ... */}
        </article>
      </section>
      <aside
        className={
          isFullImage ? "item__overlay item__overlay--active" : "item__overlay"
        }
      />
    </>
  );
};

These examples set viewTransitionName via the element's style attribute, although placing it in a CSS class is equally valid. The inline approach is shown for demonstration purposes; either method works depending on your project structure.

Finally, we refine the overlay's style that appears when the image is expanded.

.item__overlay--active {
  z-index: 2;
  display: block;
  background: rgba(0, 0, 0, 0.5);
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
}

.item__image--active {
  cursor: zoom-out;
  position: absolute;
  z-index: 9;
  top: 50%;
  left: 50%;
  transform: translate3d(-50%, -50%, 0);
  max-width: calc(100vw - 4rem);
  max-height: calc(100vh - 4rem);
}

The following demo highlights only the code relevant to the View Transitions API for easier inspection. The complete source is available in the GitHub repository for this example.

  • Live Demo 2 (museum-of-digital-wonders)

Balancing Motion And Accessibility

The View Transitions API turns what previously required a large amount of JavaScript into a relatively straightforward process, with smoother cross-page and cross-state movements as the result.

The API grants significant power and simplicity, but it demands just as much attention to accessibility as any other animation technique. Designers and developers must respect user motion preferences and avoid transitioning every element on the page. There is a fine line between interface motion that is helpful and motion that is overwhelming; finding that balance requires treating the API's capabilities with restraint.