jsguides

View Transitions API

The View Transitions API lets you animate smoothly between two DOM states — or between pages in a single-page app — without manually orchestrating before/after screenshots. The browser handles the heavy lifting: it captures the old state, applies your CSS, and animates the change.

It started as a Chrome-only API for same-document transitions but has since expanded to cover cross-document navigations and gained broader browser support.

Checking Support

if (document.startViewTransition) {
  console.log("View Transitions supported");
} else {
  console.log("Not supported — use a fallback");
}

Chrome 111+, Edge 111+. Firefox 129+ supports same-document transitions. Safari has not shipped it yet.

Your First Same-Document Transition

The core method is document.startViewTransition(). Pass it a callback that updates the DOM:

document.startViewTransition(() => {
  // Update the DOM — this becomes the "new" state
  document.querySelector("main").innerHTML = "<p>New content</p>";
});

The callback runs synchronously first, then returns a promise. When the promise resolves, the browser starts the transition on the next frame. If you return nothing (or undefined), the transition starts immediately after the callback finishes.

The result is a ViewTransition object:

const transition = document.startViewTransition(() => {
  updateTheDOM();
});

transition.finished.then(() => {
  console.log("Transition finished");
});

How the Animation Works

By default, the browser uses a crossfade — the old state fades out while the new state fades in. You can customise this with CSS pseudo-elements.

Every transition creates pseudo-elements that you can target:

Pseudo-elementWhat it represents
::view-transition-group(name)The container for one transition. Has its own animation timeline.
::view-transition-old(name)The snapshot of the old state
::view-transition-new(name)The snapshot of the new state

Name each transition by setting view-transition-name on the elements involved:

.old-element {
  view-transition-name: header;
}

.new-element {
  view-transition-name: header;
}

With names set, the browser matches them up automatically. You can then style the animation:

::view-transition-group(header) {
  animation-duration: 300ms;
  animation-timing-function: ease-out;
}

Practical SPA Example

Here’s a tab switching example — clicking a tab swaps the visible panel with a smooth transition:

const tabs = document.querySelectorAll("[data-tab]");
const panels = document.querySelectorAll("[data-panel]");

for (const tab of tabs) {
  tab.addEventListener("click", () => {
    const target = tab.dataset.tab;

    document.startViewTransition(() => {
      // Hide all panels
      for (const p of panels) {
        p.hidden = true;
      }
      // Show the selected panel
      document.querySelector(`[data-panel="${target}"]`).hidden = false;

      // Update active tab styling
      for (const t of tabs) {
        t.setAttribute("aria-selected", t.dataset.tab === target);
      }
    });
  });
}

With the default crossfade, this looks decent. But you can do better by naming the panels so the browser knows they’re the same conceptual element:

[data-panel] {
  view-transition-name: panel;
}

Now the browser knows the old panel and new panel are related and can use a transform-based animation instead of a full crossfade.

Customising the Animation

Override the default crossfade by targeting the pseudo-elements directly:

::view-transition-old(root) {
  animation: 200ms ease-out fade-out;
}

::view-transition-new(root) {
  animation: 200ms ease-out fade-in;
}

The root name refers to the default transition — the entire viewport. You can also target named elements:

::view-transition-old(panel),
::view-transition-new(panel) {
  animation: none;
  mix-blend-mode: normal;
}

This removes the default animation for the panel element. Common patterns:

/* Fast slide for list items */
::view-transition-group(list-item) {
  animation-duration: 150ms;
}

/* No animation — instant swap */
::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  animation: none;
}

Using view-transition-class

The view-transition-class CSS property lets you apply the same styles to multiple transition elements without naming each one individually:

.article-header {
  view-transition-class: article-content;
}

.article-body {
  view-transition-class: article-content;
}

::view-transition-group(article-content) {
  animation-duration: 400ms;
}

Without view-transition-class, you’d have to give both elements the same view-transition-name. This is cleaner for cases where you have multiple related elements that should animate as a unit.

Transition Types

Types let you filter which pseudo-elements participate in a transition. Useful when different parts of the page transition differently:

document.startViewTransition({
  updateCallback() {
    showNewPage();
  },
  types: ["slide"]
});

In your CSS, filter by type:

::view-transition-group(sidebar) {
  /* Only applies when the transition has type "sidebar" */
  animation-duration: 200ms;
}

Query which types are active in JavaScript via the ViewTransition object:

const transition = document.startViewTransition({ updateCallback, types: ["slide"] });

console.log(transition.types); // Set {"slide"}

Waiting for the Transition

The ViewTransition object gives you promises to hook into lifecycle events:

const transition = document.startViewTransition(() => {
  fetchNewContent(url).then(html => {
    document.querySelector("main").innerHTML = html;
  });
});

transition.ready.then(() => {
  // Animation is about to start
  console.log("Animation starting");
});

transition.finished.then(() => {
  // Animation is done
  console.log("Transition finished");
});

// Tell the browser to skip playing the animation
transition.skipCallback();

transition.skipCallback() tells the browser to skip playing the animation — useful if the user clicks something else before the transition completes.

Cross-Document Transitions

For navigations between separate HTML documents, the mechanism changes. The triggering document calls startViewTransition() as usual, but the CSS lives on the incoming page. A @view-transition rule at the top of the CSS opt-in:

/* On the incoming page */
@view-transition {
  navigation: auto;
}

This tells the browser to automatically run a view transition for any navigation to this page. You can target specific transitions with names just like same-document:

/* On both pages */
h1 {
  view-transition-name: page-title;
}

The ViewTransition object in cross-document mode is accessible from navigation.currentTransition on the new document:

// In the newly loaded document
if (navigation.currentTransition) {
  navigation.currentTransition.finished.then(() => {
    console.log("Arrived at new page");
  });
}

Styling Active Transitions

The :active-view-transition pseudo-class targets the document while a transition is in flight:

:root:active-view-transition {
  /* Apply this to the whole page during a transition */
}

:root:active-view-transition(root) {
  /* Apply this only during transitions named "root" */
}

Useful for tweaking things like scroll behavior or cursor style during a transition.

Fallback Strategy

Not every browser supports view transitions yet. Always provide a sensible fallback:

function navigateTo(url) {
  if (document.startViewTransition) {
    document.startViewTransition(() => {
      // Single-page navigation
      history.pushState({}, "", url);
      renderPage();
    });
  } else {
    // Fallback: full page navigation
    location.href = url;
  }
}

For cross-document transitions, the browser simply does a normal navigation if it doesn’t support the API — @view-transition { navigation: auto } has no effect on unsupported browsers.

Full Example

// Minimal SPA router with view transitions
async function navigate(path) {
  const target = document.querySelector(`[data-route="${path}"]`);
  if (!target) return;

  if (document.startViewTransition) {
    document.startViewTransition(() => {
      // Hide all routes
      for (const route of document.querySelectorAll("[data-route]")) {
        route.hidden = true;
      }
      // Show the target route
      target.hidden = false;
      history.pushState({}, "", path);
    });
  } else {
    // Fallback for unsupported browsers
    history.pushState({}, "", path);
    for (const route of document.querySelectorAll("[data-route]")) {
      route.hidden = route.dataset.route !== path;
    }
  }
}

// Wire up all navigation links
document.addEventListener("click", (e) => {
  const link = e.target.closest("[data-navigate]");
  if (link) {
    e.preventDefault();
    navigate(link.dataset.navigate);
  }
});
/* Route containers — named so the browser can transition them */
[data-route] {
  view-transition-name: route-panel;
}

/* Smooth crossfade with a slight slide */
::view-transition-old(route-panel) {
  animation: 250ms ease-out fade-out slide-out;
}

::view-transition-new(route-panel) {
  animation: 250ms ease-out fade-in slide-in;
}

@keyframes fade-out {
  to { opacity: 0; }
}
@keyframes fade-in {
  from { opacity: 0; }
}

See Also