Guide

View Transitions API explained

Harbor Commerce runs a client-rendered product catalog with 1,800 SKUs. Clicking a grid tile to open a detail page used to swap the entire viewport in one frame — shoppers reported the jump felt “broken,” and analytics showed a 14% back-out rate within two seconds of navigation. The View Transitions API gives the browser a first-class way to animate between DOM snapshots: capture the old state, apply your update, capture the new state, and cross-fade or morph matching elements. Harbor wired document.startViewTransition() around its router, tagged product thumbnails and hero images with matching view-transition-name values, and added a 280 ms root fade. Perceived navigation friction dropped 52% in moderated usability tests; LCP on detail pages was unchanged because transitions run on the compositor after paint. This guide covers same-document transitions in SPAs, cross-document transitions for multi-page apps, shared-element morphing, CSS customization via ::view-transition-* pseudo-elements, the Harbor Commerce refactor, a decision table against animation libraries and scroll-driven CSS, common pitfalls, and a production checklist.

What the API actually does

Traditional DOM updates are instantaneous from the user’s perspective: old nodes vanish, new nodes appear. Libraries like GSAP or Framer Motion animate properties manually, but coordinating whole-page or shared-element transitions across arbitrary layout changes is tedious. The View Transitions API delegates snapshotting and interpolation to the browser.

The flow for same-document transitions (typical SPA route change or filtered list update):

  1. Call document.startViewTransition(updateCallback).
  2. The browser captures a bitmap of the current page (the “old” snapshot).
  3. Your callback runs — mutate the DOM, swap routes, toggle themes.
  4. The browser captures the “new” snapshot and animates between them, respecting view-transition-name pairings for matched elements.

Elements without a transition name participate in the default root cross-fade. Named elements get their own layer: a thumbnail in a grid can morph into the hero image on a detail page if both share the same name during the transition window.

Cross-document view transitions extend the same machinery to full page loads: the outgoing page opts in with a @view-transition { navigation: auto; } rule (or a meta tag in supporting browsers), and the incoming page participates when it paints. This enables MPAs to feel app-like without a JavaScript framework.

JavaScript: startViewTransition and lifecycle hooks

The minimal SPA integration wraps your router’s DOM commit:

function navigate(url) {
  if (!document.startViewTransition) {
    renderRoute(url);
    return;
  }
  document.startViewTransition(() => {
    renderRoute(url);
  });
}

startViewTransition returns a ViewTransition object with promises you can await:

  • ready — pseudo-elements are created; safe to customize animations.
  • finished — transition completed or was skipped.
  • updateCallbackDone — your DOM callback finished (useful for data-fetch gating).

Pair transitions with async data by awaiting fetch inside the callback, but keep the callback fast: long tasks block the old snapshot from releasing. A common pattern fetches in parallel, starts the transition when skeleton DOM is ready, then awaits updateCallbackDone before swapping in rich content — or runs a second transition for the content reveal.

Respect prefers-reduced-motion: skip or shorten transitions when users request reduced motion. Feature-detect with if (!document.startViewTransition) and fall back to instant navigation — the API is supported in Chromium browsers and Safari 18+; Firefox support is evolving, so progressive enhancement is mandatory.

CSS: naming elements and styling pseudo-layers

Assign a unique view-transition-name to each element that should morph independently:

.product-thumb {
  view-transition-name: product-hero;
}
.detail-hero {
  view-transition-name: product-hero;
}

Names must be unique per snapshot — only one element per name on screen at capture time. For lists, generate names from IDs: view-transition-name: product-${sku} on the clicked tile and the detail hero.

Customize timing with pseudo-elements on the root group:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

::view-transition-old(product-hero),
::view-transition-new(product-hero) {
  animation-duration: 350ms;
  mix-blend-mode: normal;
}

Other useful pseudo-elements include ::view-transition-group(name) (positions the morphing box), ::view-transition-image-pair(name) (clips old/new images), and per-name old/new layers. Use animation: none on the root if you only want shared-element motion without a full-page fade.

Cross-document transitions for multi-page apps

MPAs can opt in on both sides of a navigation. On the outgoing page:

@view-transition {
  navigation: auto;
}

The browser preserves a live snapshot of the old page while the new document loads. Matching view-transition-name values on the destination page continue the morph. Requirements:

  • Same-origin navigation (typically).
  • Both documents participate — missing opt-in on either side falls back to a normal load.
  • Names and element geometry should be stable enough to interpolate; wildly different aspect ratios need custom animation overrides.

Cross-document transitions pair well with resource hints (prefetch on hover) so the incoming page paints before the morph window times out.

Harbor Commerce catalog refactor (worked example)

Harbor’s stack: Vite + vanilla router, product grid cards with 320×320 images, detail pages with a 640×640 hero. Before view transitions, route changes replaced #app innerHTML in one tick — no continuity cue.

Step 1: Router wrapper

Every internal link calls navigateWithTransition(href) which prevents default, pushes history, and wraps render() in startViewTransition. External links and form submits bypass the wrapper.

Step 2: Dynamic transition names

On grid click, Harbor sets event.currentTarget.dataset.vtName = 'sku-' + sku and applies it as view-transition-name via inline style. The detail template sets the hero’s name to the same SKU string during the first paint inside the transition callback.

Step 3: Root fade tuning

Non-hero content uses the default root cross-fade at 280 ms. Title and price text fade without individual names — too many named elements increase memory on low-end phones. Harbor caps named elements at three per transition.

Step 4: Reduced motion and fallback

@media (prefers-reduced-motion: reduce) sets root animation duration to 1 ms. Unsupported browsers call render() directly; no polyfill attempted.

Results

Moderated study (n=24): median “navigation felt smooth” Likert score rose from 2.8 to 4.3. Back-out within 2 s fell from 14% to 6.8%. Lighthouse performance score unchanged; transitions are compositor-driven and do not block the main thread beyond the DOM swap itself.

Technique decision table

Approach Best for Trade-offs
View Transitions API SPA route changes, shared-element morphs, MPA continuity Progressive enhancement; unique names per snapshot; limited Firefox
CSS / JS animation libraries Micro-interactions, parallax, complex choreography Manual snapshot pairing; no free cross-page morph
Intersection Observer + CSS Scroll-triggered reveals, lazy load fades Not tied to navigation; scroll-only
Full document reload Simple static sites, maximum compatibility Hard cuts; loses spatial context
RSC streaming + suspense Fast first paint, server-fetched UI Orthogonal to morph animation; combine both
Container queries Layout reflow inside components Layout only; no transition snapshots

View transitions solve state-to-state continuity. Pair them with container queries for responsive cards and with code splitting so route chunks load before the morph completes on slow networks.

Common pitfalls

  • Duplicate transition names: two elements with the same view-transition-name in one snapshot invalidate the name. Audit with DevTools Rendering panel.
  • Long update callbacks: fetching and parsing inside the callback delays the new snapshot. Prefetch data or show skeleton UI first.
  • Animating layout-heavy trees: morphing large subtrees is expensive. Name only the hero asset; let the root fade handle the rest.
  • Fixed headers double-fading: position:fixed elements may appear in both snapshots awkwardly. Exclude them with view-transition-name: none or a dedicated static name.
  • Accessibility neglect: always honor prefers-reduced-motion; do not use transitions to hide slow loads.
  • Assuming universal support: feature-detect and test Safari vs Chromium timing differences.
  • Clipping overflow: parent overflow: hidden can crop morph layers. Temporarily adjust overflow during transitions.

Practitioner checklist

  • Feature-detect document.startViewTransition and provide instant fallback.
  • Wrap router commits, not individual style tweaks, to avoid transition spam.
  • Generate unique view-transition-name values per shared element.
  • Cap named elements per transition for mobile GPU budgets.
  • Tune root vs named durations separately in ::view-transition-* rules.
  • Honor prefers-reduced-motion with near-zero durations.
  • Prefetch next-route assets on hover when using cross-document transitions.
  • Verify LCP and INP are not regressed — transitions should not block paint.
  • Test back-navigation: morph names must work in both directions.
  • Document which routes participate so future pages opt in consistently.

Key takeaways

  • The View Transitions API snapshots old and new DOM states and lets the browser interpolate between them.
  • view-transition-name pairs elements across snapshots for shared-element morphs.
  • Same-document transitions cover SPAs; cross-document rules extend continuity to MPAs.
  • Progressive enhancement is required — unsupported browsers should navigate instantly without breakage.
  • Combine with prefetch and code splitting so morphs mask load latency rather than exposing it.

Related reading