Web Engineering ·

View Transitions API in Production: Smooth Page Navigation, Persistent Elements, and Animation Patterns for Modern Web Apps

The View Transitions API lets you animate between UI states without a JavaScript animation library. Here is how it actually works, where it breaks, and how to ship it in React, Next.js, and Astro without breaking users on unsupported browsers.

View Transitions API in Production: Smooth Page Navigation, Persistent Elements, and Animation Patterns for Modern Web Apps

Most navigation animation solutions fall into one of two categories: JavaScript animation libraries that clone DOM nodes and tween them manually, or CSS tricks with position: fixed elements and z-index games. Both require a non-trivial amount of code to handle edge cases, and both fight the browser’s natural rendering pipeline.

The View Transitions API is a different model. The browser takes a screenshot of the current state, commits the new state, then animates between the two snapshots. You get a crossfade for free, and you can opt specific elements into position/size animations by giving them a view-transition-name. The browser handles the compositor-layer work. You write CSS.

This article covers how the API works mechanically, how to use it for real patterns (list-to-detail, tabs, MPA navigation), how to wire it into React, Next.js, and Astro, and what to watch out for in production.

How the API Works

The core is document.startViewTransition(callback). The callback is a function that updates the DOM. The browser captures before and after states and animates between them.

async function navigateTo(url: string): Promise<void> {
  if (!document.startViewTransition) {
    // Unsupported browser: just navigate
    window.location.href = url;
    return;
  }

  const response = await fetch(url);
  const html = await response.text();

  document.startViewTransition(() => {
    document.documentElement.innerHTML = html;
  });
}

The callback can return a promise. The browser waits for it to resolve before taking the “after” snapshot. This is where you fetch data, update state, or swap components.

document.startViewTransition(async () => {
  const data = await fetchItemDetails(itemId);
  renderDetailView(data);
});

The default animation is a crossfade. The entire page fades out and the new state fades in over 250ms. That is already better than a hard cut, but the interesting cases are persistent elements.

Persistent Elements with view-transition-name

When you assign view-transition-name to an element in both the old and new state, the browser treats it as a shared element and animates its position, size, and shape between the two states.

/* List view: thumbnail */
.product-card .thumbnail {
  view-transition-name: product-thumbnail;
  contain: layout;
}

/* Detail view: hero image */
.product-hero {
  view-transition-name: product-thumbnail;
  contain: layout;
}

The name must be unique on the page at any given moment. Two elements with the same name in the same snapshot will cause the browser to fall back to a crossfade for both. If you have a list of cards, you need dynamic names:

function renderProductCard(product: Product): void {
  const card = document.createElement('div');
  card.innerHTML = `
    <img
      src="${product.thumbnailUrl}"
      alt="${product.name}"
      style="view-transition-name: product-thumbnail-${product.id}; contain: layout"
    />
  `;
  listContainer.appendChild(card);
}

function renderProductDetail(product: Product): void {
  heroImage.style.viewTransitionName = `product-thumbnail-${product.id}`;
}

The contain: layout is required. Without layout containment, the browser cannot reliably capture the element’s position independently from its ancestors.

When the transition runs, the browser creates four pseudo-elements you can style with CSS:

/* The outgoing (old) state */
::view-transition-old(product-thumbnail) {
  animation: fade-out 300ms ease-out;
}

/* The incoming (new) state */
::view-transition-new(product-thumbnail) {
  animation: fade-in 300ms ease-in;
}

/* The shared element wrapper (handles position/size animation) */
::view-transition-image-pair(product-thumbnail) {
  /* You usually leave this alone */
}

/* The root crossfade wrapper */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 200ms;
}

For the hero image expansion, you typically want to suppress the old/new crossfade and let the position morph do the work:

::view-transition-old(product-thumbnail),
::view-transition-new(product-thumbnail) {
  /* Let the morph handle it; don't layer a crossfade on top */
  animation: none;
  mix-blend-mode: normal;
}

Cross-Document View Transitions (MPA Support)

For multi-page apps and server-rendered sites, Chrome 126 added cross-document view transitions. No JavaScript required for the basic case. You opt in with a meta tag or HTTP header:

<!-- In <head> of both pages -->
<meta name="view-transition" content="same-origin" />

The browser coordinates the transition automatically on same-origin navigations. view-transition-name values on both pages are matched by name. This works with <a> clicks, form submissions, and history.pushState.

For finer control, use the pagereveal and pageswap events:

// On the incoming page
window.addEventListener('pagereveal', (event: PageRevealEvent) => {
  if (!event.viewTransition) return;

  // Determine which element should be the shared target
  const targetId = new URLSearchParams(location.search).get('from');
  if (targetId) {
    const hero = document.querySelector('.hero-image') as HTMLElement;
    hero.style.viewTransitionName = `card-${targetId}`;
  }
});

// On the outgoing page
window.addEventListener('pageswap', (event: PageSwapEvent) => {
  if (!event.viewTransition) return;

  // You can call event.viewTransition.skipTransition() here
  // if conditions aren't right (e.g., user navigating back to a list)
});

The NavigationActivation object on pagereveal tells you where the user came from, which is useful for conditionally applying transitions only on specific navigation paths.

React Integration

React 18 and 19 both have issues with View Transitions because React batches DOM updates asynchronously. If you call startViewTransition and then update state, React may not flush the update synchronously during the transition callback.

The safest pattern is to flush React’s render synchronously inside the callback using flushSync:

import { flushSync } from 'react-dom';
import { useState } from 'react';

function useViewTransition() {
  return function startTransition(updateFn: () => void): void {
    if (!document.startViewTransition) {
      updateFn();
      return;
    }

    document.startViewTransition(() => {
      flushSync(updateFn);
    });
  };
}

// Usage
function ProductList({ products }: { products: Product[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const startTransition = useViewTransition();

  function handleSelect(id: string): void {
    startTransition(() => setSelectedId(id));
  }

  if (selectedId) {
    const product = products.find(p => p.id === selectedId)!;
    return <ProductDetail product={product} onBack={() => startTransition(() => setSelectedId(null))} />;
  }

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          <img
            src={product.thumbnailUrl}
            style={{ viewTransitionName: `thumb-${product.id}` }}
            onClick={() => handleSelect(product.id)}
          />
        </li>
      ))}
    </ul>
  );
}

React 19 introduced useViewTransition as an experimental hook that handles the flushSync coordination internally. As of early 2026 it is still behind a flag. Until it stabilizes, the flushSync wrapper above is the production-safe approach.

Next.js Integration

Next.js App Router uses the browser’s navigation APIs, which means cross-document transitions work with the meta tag approach for full navigations. For client-side navigations via <Link>, you need to intercept at the router level.

// app/components/TransitionLink.tsx
'use client';

import { useRouter } from 'next/navigation';
import { flushSync } from 'react-dom';
import type { MouseEvent } from 'react';

interface TransitionLinkProps {
  href: string;
  children: React.ReactNode;
  className?: string;
}

export function TransitionLink({ href, children, className }: TransitionLinkProps) {
  const router = useRouter();

  function handleClick(e: MouseEvent<HTMLAnchorElement>): void {
    e.preventDefault();

    if (!document.startViewTransition) {
      router.push(href);
      return;
    }

    document.startViewTransition(() => {
      flushSync(() => {
        router.push(href);
      });
    });
  }

  return (
    <a href={href} onClick={handleClick} className={className}>
      {children}
    </a>
  );
}

The catch: router.push in Next.js App Router is not synchronous. The transition callback resolves before the new page content renders. You will get a crossfade of the loading state, not the final content. To work around this, you need to treat the transition as “animate out, wait for navigation, animate in”:

export function TransitionLink({ href, children, className }: TransitionLinkProps) {
  const router = useRouter();

  async function handleClick(e: MouseEvent<HTMLAnchorElement>): Promise<void> {
    e.preventDefault();

    if (!document.startViewTransition) {
      router.push(href);
      return;
    }

    // Animate the current page out, then navigate
    const transition = document.startViewTransition();
    await transition.ready;
    router.push(href);
    // The new page's View Transition meta tag will handle the in animation
  }

  return (
    <a href={href} onClick={handleClick} className={className}>
      {children}
    </a>
  );
}

This is imperfect. The cleanest Next.js approach is using cross-document transitions for full navigations and reserving startViewTransition for in-page state changes (tab switches, list/detail toggling within a route).

Astro Integration

Astro has first-party View Transitions support via @astrojs/view-transitions. Add the ViewTransitions component to your layout:

---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
  <head>
    <ViewTransitions />
  </head>
  <body>
    <slot />
  </body>
</html>

This handles the cross-document coordination and provides fallback for unsupported browsers. You can annotate elements with transition:name (Astro’s syntax for view-transition-name) and transition:animate for built-in animation presets:

---
// src/pages/blog/[slug].astro
const { post } = Astro.props;
---
<article>
  <img
    src={post.coverImage}
    alt={post.title}
    transition:name={`cover-${post.slug}`}
  />
  <h1 transition:name={`title-${post.slug}`} transition:animate="slide">
    {post.title}
  </h1>
</article>

Astro’s built-in animations (fade, slide, none) are shorthand for common CSS animation patterns. For custom animations, you use the same CSS pseudo-element selectors.

One thing Astro handles that you would otherwise build manually: it serializes page state across navigations so that interactive islands (React/Svelte components) can persist their state during transitions using the transition:persist directive.

Real-World Patterns

List to detail. The thumbnail-to-hero morph is the most common use case. The key decision is whether to animate the position morph (FLIP-style, where the element appears to move and resize) or just crossfade with matching names. The morph looks better when the element is prominently featured in both views. When the element is small in the list and large in the detail, the position morph is worth it. When both sizes are similar, a crossfade is cleaner.

Tab switching. For tabs with a sliding indicator, use view-transition-name: tab-indicator on the active tab underline. The browser morphs it to the new position automatically. For tab content, a directional slide makes the spatial relationship clear:

@keyframes slide-in-from-right {
  from { transform: translateX(100%); opacity: 0; }
  to   { transform: translateX(0);    opacity: 1; }
}

@keyframes slide-out-to-left {
  from { transform: translateX(0);    opacity: 1; }
  to   { transform: translateX(-100%); opacity: 0; }
}

::view-transition-new(tab-content) {
  animation: slide-in-from-right 250ms ease-out;
}

::view-transition-old(tab-content) {
  animation: slide-out-to-left 250ms ease-out;
}

To reverse the direction when navigating backwards, set a data attribute before starting the transition and use it in a CSS selector:

function switchTab(direction: 'forward' | 'backward', updateFn: () => void): void {
  document.documentElement.dataset.transitionDirection = direction;

  document.startViewTransition(() => {
    flushSync(updateFn);
  });
}
[data-transition-direction="backward"] ::view-transition-new(tab-content) {
  animation: slide-in-from-left 250ms ease-out;
}

Navigation transitions. For page-level transitions in an SPA, the simplest option is a full-page crossfade with a slightly longer duration than the default. The default 250ms is often too fast to read. 350-400ms with an ease-out curve reads as intentional, not jarring.

Performance Considerations

View transitions pause rendering during the capture phase. On slow devices, a complex page with many layers can cause a noticeable freeze before the animation starts. Keep the DOM shallow during transitions if possible.

The browser captures everything painted at the time startViewTransition is called. Lazy-loaded images that have not loaded yet will appear as blank in the old snapshot. Use decoding="sync" on images that need to be in the snapshot, or load them eagerly when they are likely transition targets.

Named view-transition elements get promoted to their own compositor layers. Having more than 10-15 named elements on a page simultaneously can cause memory pressure. Assign names dynamically and remove them after the transition completes:

async function transitionToDetail(id: string): Promise<void> {
  const card = document.querySelector(`[data-id="${id}"]`) as HTMLElement;

  // Assign name just before transition
  card.style.viewTransitionName = `card-${id}`;

  await document.startViewTransition(() => {
    flushSync(() => setView({ type: 'detail', id }));
  }).finished;

  // Remove name after transition completes
  card.style.viewTransitionName = '';
}

Prefer transform and opacity in your custom animation keyframes. The browser already uses these for the default transition. Adding width, height, or top/left in keyframes forces layout recalculation and defeats the compositor-layer advantage.

Progressive Enhancement

The feature is behind a document.startViewTransition check. Always have a fallback:

function withViewTransition(updateFn: () => void): void {
  if (!document.startViewTransition) {
    updateFn();
    return;
  }
  document.startViewTransition(() => flushSync(updateFn));
}

For cross-document transitions, the meta tag approach degrades gracefully. Browsers that do not support it ignore the tag and perform a standard navigation. Astro’s ViewTransitions component includes a JavaScript polyfill that handles graceful degradation automatically, including for Firefox which as of early 2026 does not support the API.

The @media (prefers-reduced-motion: reduce) query applies to view transitions. The browser reduces motion in its default animations when the user has opted into reduced motion. For custom animations, you need to handle this explicitly:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.01ms;
  }

  ::view-transition-old(product-thumbnail),
  ::view-transition-new(product-thumbnail) {
    animation: none;
  }
}

Tradeoffs

ApproachImplementation costBrowser supportMotion qualityComplexity ceiling
CSS transitions on shared stateLowAll browsersLimitedLow
JS animation library (GSAP, Motion)MediumAll browsersHighHigh
View Transitions API (SPA)LowChrome, Edge, Safari 18+HighMedium
View Transitions API (MPA/cross-doc)Very lowChrome 126+, Safari 18+HighLow
Astro ViewTransitionsVery lowAll (polyfilled)Medium-highLow

Firefox is the main gap as of early 2026. If your user base is Firefox-heavy, the cross-document approach with Astro’s polyfill is the safest path. For Chrome-dominant products (developer tools, internal apps), shipping the native API without a polyfill is fine.

The Right Mental Model

Think of startViewTransition as a boundary around a state change, not as an animation primitive. You describe what changes (call your update function), and the browser figures out how to animate it based on what has view-transition-name assigned. The API does not care whether you are using React, vanilla JS, or a web component. It operates at the DOM level.

The named element system is where the power is. A crossfade between two full pages is a marginal improvement over a hard cut. A thumbnail that morphs into a hero image, or a card that expands into a modal, communicates spatial relationships that users understand intuitively. That cognitive continuity is the actual value. The API gives you that without a single getBoundingClientRect call or manually cloned node.

Start with cross-document transitions and the meta tag. Add view-transition-name to the two or three elements that matter most for spatial continuity. Measure whether the transition is fast enough on mid-range Android devices. Then layer in custom CSS animations if the defaults do not fit your design. That order matters: cross-document first, named elements second, custom animation last.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.