SPA Page Swap Animations with the View Transitions API

The View Transitions API eliminates the need for JavaScript transition libraries by capturing compositor-layer snapshots of the DOM before and after a mutation, then crossfading or morphing them entirely off the main thread. For SPA route changes, document.startViewTransition() wraps your router’s DOM-update callback, generates ::view-transition-old and ::view-transition-new pseudo-elements, and hands control to CSS — the same compositor-thread model that powers pure-CSS parallax with animation-timeline.

The pages below cover the full production workflow: API syntax, scroll-timeline synchronization after navigation, compositor-safe property choices, browser support guards, and the accessibility obligations that apply to every route change.

Pages in this section


Transition lifecycle diagram

View Transitions API lifecycle Five sequential phases: startViewTransition called → old state captured (screenshot) → DOM callback runs → new state captured → compositor animates old→new pseudo-elements simultaneously. startView Transition() called Old state captured paused DOM callback runs updateDOM() New state captured resumes Compositor animates ::vt-old → ::vt-new t=0ms callback resolves ~300ms All compositor work runs off the main thread — zero layout cost per frame

Syntax reference

document.startViewTransition() accepts a synchronous or async callback. It returns a ViewTransition object with three promises:

// Signature
const transition = document.startViewTransition(callback);

// Promises on the returned object
transition.ready;    // resolves when pseudo-elements are created; use for JS-driven animations
transition.finished; // resolves when animation is complete and pseudo-elements removed
transition.updateCallbackDone; // resolves when callback finishes (before animation starts)

// Imperative abort
transition.skipTransition(); // skips to end state, cancels animation

CSS pseudo-elements generated per named element:

/* Default root capture */
::view-transition-old(root)   { /* outgoing snapshot */ }
::view-transition-new(root)   { /* incoming snapshot */ }

/* Named element capture */
::view-transition-old(hero-image)  { }
::view-transition-new(hero-image)  { }

/* Group wrapper (controls positioning) */
::view-transition-group(hero-image) { }

Assign view-transition-name in CSS to opt individual elements into the named-capture path:

.hero-image {
  view-transition-name: hero-image;
  contain: layout;            /* required — name must be unique in the DOM */
}

Property defaults relevant to the pseudo-elements: animation-duration defaults to 0.25s; animation-timing-function defaults to ease; mix-blend-mode on ::view-transition-old defaults to normal.

Minimal working example

A self-contained SPA swap with feature detection and motion guard — no build tools, no framework:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    /* Outgoing page slides left and fades */
    ::view-transition-old(root) {
      animation: page-out 0.28s ease-in forwards;
    }
    /* Incoming page slides in from right and fades */
    ::view-transition-new(root) {
      animation: page-in 0.28s ease-out forwards;
    }

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

    /* Kill all motion for users who request it */
    @media (prefers-reduced-motion: reduce) {
      ::view-transition-old(root),
      ::view-transition-new(root) {
        animation: none;
      }
    }

    main { padding: 2rem; font-family: system-ui, sans-serif; }
  </style>
</head>
<body>
  <nav>
    <a href="#" onclick="go('page-a'); return false;">Page A</a>
    <a href="#" onclick="go('page-b'); return false;">Page B</a>
  </nav>
  <main id="content">
    <h1>Page A</h1>
    <p>Initial content.</p>
  </main>

  <div id="route-announcer" aria-live="polite" aria-atomic="true"
       style="position:absolute;clip:rect(0,0,0,0);width:1px;height:1px;overflow:hidden;"></div>

  <script>
    const pages = {
      'page-a': '<h1>Page A</h1><p>Content for page A.</p>',
      'page-b': '<h1>Page B</h1><p>Content for page B.</p>',
    };

    async function go(id) {
      if (!document.startViewTransition) {
        // Graceful degradation: plain swap, no animation
        document.getElementById('content').innerHTML = pages[id];
        return;
      }

      const transition = document.startViewTransition(() => {
        document.getElementById('content').innerHTML = pages[id];
        document.title = id.replace('-', ' ').replace(/\b\w/g, c => c.toUpperCase());
      });

      await transition.finished;

      // Focus management — place focus on the new heading
      const h1 = document.querySelector('main h1');
      if (h1) {
        h1.setAttribute('tabindex', '-1');
        h1.focus({ preventScroll: true });
        h1.removeAttribute('tabindex');
      }

      // Screen-reader announcement
      const announcer = document.getElementById('route-announcer');
      if (announcer) announcer.textContent = `Navigated to ${document.title}`;
    }
  </script>
</body>
</html>

Scroll-timeline synchronization after navigation

When a route change fires, any active animation-timeline: scroll() binding on the incoming page may initialize before the browser’s scroll position settles, causing parallax layers to jump visibly. The fix is two-part: disable automatic scroll restoration and defer any scroll position write until after the transition animation completes.

// Set once at app initialization — before any navigation
window.history.scrollRestoration = 'manual';

async function navigateWithScrollRestore(url, savedScrollY) {
  const transition = document.startViewTransition(() => updateDOM(url));

  // Wait for animation to finish before touching scroll position
  await transition.finished;

  if (savedScrollY != null) {
    // 'instant' prevents a visible scroll jump from triggering scroll-driven animations
    window.scrollTo({ top: savedScrollY, behavior: 'instant' });
  }
}

For a thorough explanation of the animation-timeline: view() scoping model that parallax layers depend on, see parallax effects with pure CSS and animation-timeline.

When using named scroll timelines (scroll-timeline-name) on elements inside the route container, those timelines are torn down when the container is removed from the DOM during the callback. Recreate them after transition.updateCallbackDone resolves if you need continuity across routes.

Compositor-safe properties

Only animate properties that the compositor can handle without triggering layout or paint. Animating width, height, top, or margin on the pseudo-elements forces synchronous layout recalculation every frame, shattering the 150–300ms animation budget.

Property Compositor-thread Forces layout Notes
opacity Yes No Ideal for crossfade transitions
transform (translate, scale, rotate) Yes No Preferred for movement and morphing
filter (blur, brightness) Yes* No *Promoted to compositor only when on a layer
clip-path Partial No† †Simple shapes OK; complex paths may rasterize
width / height No Yes Never animate on ::view-transition pseudo-elements
background-color No No Triggers paint; avoid in tight loops
border-radius Partial No Rasterized when combined with transforms

Apply contain and will-change to the route wrapper so the browser can promote it to its own compositor layer before the transition starts:

.route-container {
  contain: layout style paint;
  will-change: transform, opacity;
}

contain: layout style paint establishes a formatting-context boundary. The browser skips style invalidation for ancestor elements during the transition, keeping frame times well under 16ms. Remove will-change from elements that are not actively transitioning — it consumes GPU memory continuously.

Common implementation patterns

Pattern 1: Directional slide based on navigation direction

Detect whether the user is navigating forward or back, and flip the animation direction to match:

let navDirection = 'forward'; // track in router state

async function navigate(url, direction = 'forward') {
  navDirection = direction;
  document.documentElement.dataset.navDirection = direction;

  const transition = document.startViewTransition(() => updateDOM(url));
  await transition.finished;

  delete document.documentElement.dataset.navDirection;
}
/* Default: forward (right → center, center → left) */
::view-transition-old(root) {
  animation: slide-out-left 0.3s ease-in forwards;
}
::view-transition-new(root) {
  animation: slide-in-right 0.3s ease-out forwards;
}

/* Back navigation: reverse the directions */
[data-nav-direction="back"] ::view-transition-old(root) {
  animation: slide-out-right 0.3s ease-in forwards;
}
[data-nav-direction="back"] ::view-transition-new(root) {
  animation: slide-in-left 0.3s ease-out forwards;
}

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

Pattern 2: Shared element promotion (hero transition)

Promote a specific element — a card image, a title — into its own capture so it morphs independently while the rest of the page crossfades. This technique is the foundation for cross-route element morphing.

/* On the list route */
.card-thumbnail[data-id="42"] {
  view-transition-name: hero-42;
  contain: layout;
}

/* On the detail route — same name, different element */
.detail-hero[data-id="42"] {
  view-transition-name: hero-42;
  contain: layout;
}
async function openDetail(id) {
  // Set the name only for the duration of the transition
  document.querySelector(`.card-thumbnail[data-id="${id}"]`)
    .style.viewTransitionName = `hero-${id}`;

  const transition = document.startViewTransition(() => renderDetail(id));
  await transition.ready; // pseudo-elements now exist

  // Optionally drive with WAAPI for spring physics
  document.querySelector(`::view-transition-group(hero-${id})`)
    ?.animate({ /* custom keyframes */ }, { duration: 400, easing: 'ease-out' });

  await transition.finished;
}

Pattern 3: Staggered content reveal post-transition

Use transition.finished as the trigger to start scroll-driven or time-based reveals on the incoming page’s content:

async function navigate(url) {
  const transition = document.startViewTransition(() => updateDOM(url));
  await transition.finished;

  // Trigger section reveals — animation-timeline: view() picks up from here
  document.querySelectorAll('.reveal-on-scroll').forEach(el => {
    el.classList.add('ready'); // CSS handles the rest via scroll()
  });
}
.reveal-on-scroll {
  opacity: 0;
  transform: translateY(16px);
}

.reveal-on-scroll.ready {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
}

@keyframes reveal {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: translateY(0); }
}

Pattern 4: Rapid-navigation abort guard

If the user triggers a second navigation before the first transition finishes, the old ViewTransition is automatically aborted and transition.finished rejects with an AbortError. Guard against unhandled rejections:

let activeTransition = null;

async function navigate(url) {
  // Cancelling previous transition is automatic — just track for cleanup
  activeTransition = document.startViewTransition(() => updateDOM(url));

  try {
    await activeTransition.finished;
    announceNavigation();
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
    // Transition was superseded — the new one will handle announcement
  }
}

Browser support and @supports guard

The same-document View Transitions API shipped in Chrome 111 (March 2023) and Safari 18 (September 2024). Firefox 131+ has it behind dom.viewTransitions.enabled. For comprehensive feature detection patterns across all animation APIs, see browser support and progressive enhancement for CSS animations.

Feature Chrome Safari Firefox Edge
document.startViewTransition 111 (Mar 2023) 18 (Sep 2024) 131 (flag) 111 (Mar 2023)
view-transition-name (CSS) 111 18 131 (flag) 111
::view-transition-* pseudo-elements 111 18 131 (flag) 111
@view-transition (MPA / cross-document) 126 18.2 Not shipped 126
transition.types (typed transitions) 125 Not shipped Not shipped 125

Wrap scroll-driven animation integration in @supports to prevent double-failure on older engines:

/* Scroll-driven reveal only if scroll-timeline is supported */
@supports (animation-timeline: scroll()) {
  .reveal-on-scroll.ready {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 40%;
  }
}

/* View transition styles only run when the API is available */
@supports (view-transition-name: none) {
  ::view-transition-old(root) {
    animation: page-out 0.28s ease-in forwards;
  }
  ::view-transition-new(root) {
    animation: page-in 0.28s ease-out forwards;
  }
}

For respecting prefers-reduced-motion in scroll-driven and view-transition animations, the simplest production pattern is:

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

Using animation-duration: 0.01ms rather than none preserves the transition.finished resolution path — none can leave promises in a permanently pending state in some engines.

Gotchas and failure modes

  1. Duplicate view-transition-name values crash the transition. If two elements in the active DOM carry the same name when startViewTransition fires, the entire transition is skipped — no error is thrown in the console by default. Verify uniqueness with document.querySelectorAll('[style*="view-transition-name"]') before triggering, or scope names dynamically per item ID.

  2. contain: layout is required alongside view-transition-name. The spec mandates that any element with a view-transition-name must establish a containing block. Omitting it produces spec-violating behavior and renders inconsistently across Chrome and Safari.

  3. Scroll-timeline jumps when scrollRestoration is left on auto. Without window.history.scrollRestoration = 'manual', the browser may restore scroll position mid-transition, causing animation-timeline: scroll() values to change abruptly. Set it before your first navigation.

  4. transition.finished rejects on rapid navigation. The AbortError is intentional; it signals the transition was superseded. Uncaught rejections surface as unhandled promise errors in the console and can break error-monitoring tools. Always .catch() or try/catch transition.finished.

  5. Safari 18 does not support transition.types. The typed-transition API (document.startViewTransition({ update, types })) that enables conditional CSS targeting via html:active-view-transition-type(...) is Chrome 125+ only. Feature-detect with 'types' in ViewTransition.prototype before using it.

  6. will-change: transform on body promotes the entire page to a new GPU layer, bloating VRAM on mobile devices. Scope will-change to .route-container only and remove it once the transition finishes by toggling a class.

Performance checklist


Up: Scroll-Driven & View Transition Implementation Patterns