View Transitions with the Navigation API

After reading this you will be able to animate route changes by combining document.startViewTransition with a Navigation API intercept — including direction-aware animations, shared element morphs, and the reduced-motion and error paths that decide whether it feels polished or broken.

← Back to The Navigation API

Prerequisites

Core Concept

document.startViewTransition(callback) snapshots the current visual state, runs your callback to update the DOM, snapshots the new state, and cross-fades between them using CSS animations on generated pseudo-elements. Elements marked with a view-transition-name are snapshotted separately, so the browser can morph one into the other rather than fading the whole page.

The Navigation API is what makes this comfortable for a router. navigateEvent.intercept({ handler }) hands you a callback whose returned promise defines when the navigation is complete — precisely the shape startViewTransition wants. The two compose without any glue beyond returning one from the other.

The pairing also solves the problem that makes View Transitions awkward with plain pushState: knowing whether the navigation is forward or backward. navigateEvent.navigationType reports "push", "replace", "reload" or "traverse" directly, and navigation.currentEntry.index compared against the destination’s index gives the direction of a traversal — no index-stamping scheme required, unlike the popstate approach.

Three constraints shape every real implementation. The transition holds a snapshot of the page, so nothing paints until the callback’s promise settles — a slow data fetch inside it freezes the UI. Only one transition can run at a time; starting another skips the first. And view-transition-name must be unique per snapshot, so two elements sharing a name in the same frame abort the transition entirely.

How a view transition composes with a navigation intercept A sequence: the navigate event is intercepted, the handler starts a view transition, the browser snapshots the old state, the callback swaps the DOM, the browser snapshots the new state and animates between them, and the navigation completes when the animation finishes. navigate event intercept({ handler }) startViewTransition returns .finished snapshot old page frozen here keep the callback fast swap the DOM your render call snapshot new · animate pseudo-elements ::view-transition-old and ::view-transition-new navigation complete when .finished resolves Fetch data BEFORE starting the transition — anything awaited inside the callback freezes the page.
The intercept handler's promise and the transition's lifecycle line up exactly — the only rule is that data must be ready before the snapshot is taken.

Implementation

// TypeScript 5.x — the composition, with every guard that matters

declare global {
  interface Document {
    startViewTransition?: (cb: () => void | Promise<void>) => {
      finished: Promise<void>;
      ready: Promise<void>;
      updateCallbackDone: Promise<void>;
    };
  }
}

const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");

navigation.addEventListener("navigate", (event) => {
  if (!event.canIntercept || event.hashChange || event.downloadRequest) return;

  const url = new URL(event.destination.url);
  if (url.origin !== location.origin) return;

  // Direction comes free with the Navigation API.
  const direction =
    event.navigationType === "traverse"
      ? event.destination.index < navigation.currentEntry!.index
        ? "back"
        : "forward"
      : "forward";

  event.intercept({
    async handler() {
      // 1. Fetch FIRST. Anything awaited inside the transition freezes the page.
      const view = await resolveRoute(url.pathname);

      // 2. No transition when the user asked for reduced motion, or when the
      //    browser lacks the API — the swap still happens, just instantly.
      if (!document.startViewTransition || prefersReducedMotion.matches) {
        renderView(view);
        return;
      }

      // 3. Expose direction to CSS so the animation can differ.
      document.documentElement.dataset.transition = direction;

      const transition = document.startViewTransition(() => {
        renderView(view); // synchronous DOM swap
      });

      try {
        await transition.finished;
      } finally {
        delete document.documentElement.dataset.transition;
      }
    },
  });
});

The CSS does the animation. Directional variants key off the data attribute the handler set:

/* Default cross-fade is provided by the browser; override for direction. */
@media (prefers-reduced-motion: no-preference) {
  :root[data-transition="forward"]::view-transition-old(root) {
    animation: slide-out-left 220ms ease both;
  }
  :root[data-transition="forward"]::view-transition-new(root) {
    animation: slide-in-right 220ms ease both;
  }
  :root[data-transition="back"]::view-transition-old(root) {
    animation: slide-out-right 220ms ease both;
  }
  :root[data-transition="back"]::view-transition-new(root) {
    animation: slide-in-left 220ms ease both;
  }
}

@keyframes slide-out-left  { to   { transform: translateX(-24px); opacity: 0; } }
@keyframes slide-in-right  { from { transform: translateX(24px);  opacity: 0; } }
@keyframes slide-out-right { to   { transform: translateX(24px);  opacity: 0; } }
@keyframes slide-in-left   { from { transform: translateX(-24px); opacity: 0; } }

Shared-element morphs — a thumbnail growing into a hero image — need a name that is unique in each snapshot and identical across the two:

// TypeScript 5.x — assign the name only to the element being navigated from

export function markSharedElement(itemId: string): void {
  // Clear any previous holder first: two elements with the same name in one
  // frame abort the whole transition.
  document.querySelectorAll<HTMLElement>("[style*='view-transition-name']").forEach((el) => {
    el.style.viewTransitionName = "";
  });

  const source = document.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(itemId)}"] img`);
  if (source) source.style.viewTransitionName = "hero-image";
}
/* The destination element claims the same name, so the browser morphs between them. */
.product-detail__hero { view-transition-name: hero-image; }

::view-transition-old(hero-image),
::view-transition-new(hero-image) {
  /* Prevent the default cross-fade so the morph reads as one element moving. */
  mix-blend-mode: normal;
  animation-duration: 260ms;
}
A shared element morph between two routes A thumbnail in a list carries the view transition name hero image. On the detail route, the large hero image claims the same name, so the browser animates one into the other rather than cross-fading the whole page. list route thumb Product name view-transition-name: hero-image thumb Another product no name — not the source morph detail route hero image same name claimed here the browser interpolates position, size and aspect two holders = no transition
The name is a handle, not a style: exactly one element may carry it per snapshot, and the browser interpolates between the two frames that do.

Deciding which routes deserve an animation

Animating everything is the fastest way to make an application feel slower than it is. A transition adds real latency — the snapshot, the animation duration, and the delay before focus moves and the route is announced — so it should buy something in return.

It buys the most when the two views are spatially related: a list and one of its items, a wizard step and the next, a parent and a child in a drawer. Here the animation communicates where the new content came from, which is information a hard cut does not carry. A shared element morph in particular replaces an entire sentence of explanation with 260 milliseconds of motion.

It buys nothing between unrelated top-level areas. Sliding from the dashboard to account settings implies a spatial relationship that does not exist, and users read the delay as sluggishness rather than as polish. A plain cut, or at most a very short cross-fade, serves those navigations better.

The middle case is a list-to-list navigation such as paging or filtering. A directional slide works well when the user moved deliberately (next page) and badly when the content changed underneath them (a filter applied). That distinction maps exactly onto push versus replace, which the Navigation API already gives you in navigationType — one more reason to derive the decision from the event rather than from the route.

Verification

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("route changes animate and never leave a stuck frame", async ({ page }) => {
  await page.goto("/products");

  const started = page.evaluate(
    () => new Promise<boolean>((resolve) => {
      const original = document.startViewTransition!.bind(document);
      document.startViewTransition = (cb) => { resolve(true); return original(cb); };
      setTimeout(() => resolve(false), 3000);
    }),
  );

  await page.getByRole("link", { name: "Laptop" }).click();
  expect(await started).toBe(true);

  // The pseudo-element must be gone once the transition finishes.
  await expect(page.locator("[data-route='product-detail']")).toBeVisible();
  const stuck = await page.evaluate(() => document.documentElement.dataset.transition);
  expect(stuck).toBeUndefined();
});

test("reduced motion skips the animation but not the navigation", async ({ page }) => {
  await page.emulateMedia({ reducedMotion: "reduce" });
  await page.goto("/products");
  await page.getByRole("link", { name: "Laptop" }).click();
  await expect(page.locator("[data-route='product-detail']")).toBeVisible();
});

In DevTools, the Animations panel lists the view-transition pseudo-elements while a transition runs, and slowing playback to 10% makes it obvious whether a shared element is morphing or merely cross-fading. If the whole page fades when you expected a morph, the name is either missing on one side or held by two elements at once.

Gotchas

  • Awaiting inside the transition callback freezes the page. The old snapshot is displayed until the callback resolves; fetch before starting the transition, not inside it.
  • Duplicate view-transition-name values abort the transition silently. Clear the previous holder before assigning a new one.
  • Only one transition runs at a time. Rapid clicking skips animations, which is correct behaviour — do not try to queue them.
  • Fixed and sticky elements are snapshotted with the root unless given their own name, so a sticky header slides with the page and looks wrong.
  • Reduced motion must skip the animation, not shorten it. Users who set it are asking for no motion at all.
  • Feature-detect rather than assume. Outside Chromium the API may be absent; the fallback must still perform the navigation.
  • Long transitions delay the navigation’s completion, which delays focus and announcements. Keep them under about 300 ms.
Transition duration bands and how each is perceived Four duration bands from zero to four hundred milliseconds, each with how it is perceived and what it suits. Above three hundred milliseconds the transition begins to delay the route announcement and focus move. Transition durations and what each communicates reads as use for 0ms — no transition instant reduced motion, unrelated areas 120–180ms a crisp cut list to list, pagination 220–300ms a deliberate move list to detail, shared element 400ms+ sluggish almost never — it delays the announcement
Anything above roughly 300 ms starts delaying the focus move and the announcement, which costs more than the polish is worth.

FAQ

Do I need the Navigation API to use View Transitions? No — startViewTransition works with any DOM update, including one triggered by pushState. The Navigation API just supplies the promise contract and the direction for free.

What happens in browsers without support? The feature check falls through to a plain render. The navigation works identically; only the animation is absent.

Can I animate between two documents? Yes, with cross-document view transitions and the @view-transition at-rule — a different mechanism aimed at multi-page sites rather than client routers.

Why does my shared element cross-fade instead of morphing? Almost always because the name is not present on both sides at the right moment, or because two elements held it simultaneously in the outgoing frame.

Does this hurt accessibility? Only if reduced motion is ignored or the transition is long enough to delay the route announcement. Handled properly it is neutral, and directional animation genuinely helps some users understand where they went.