Skip to content

Fix: View Transitions API Not Working — No Animation Between Pages, Cross-Document Transitions Failing, or Fallback Missing

FixDevs ·

Quick Answer

How to fix View Transitions API issues — same-document transitions, cross-document MPA transitions, view-transition-name CSS, Next.js and Astro integration, custom animations, and browser support.

The Problem

document.startViewTransition() is called but no animation plays:

document.startViewTransition(() => {
  updateDOM();
});
// DOM updates but no visual transition

Or cross-document transitions don’t work between page navigations:

@view-transition { navigation: auto; }
/* Navigating between pages — no transition */

Or the transition plays but specific elements don’t animate independently:

.hero-image { view-transition-name: hero; }
/* Image still fades with the entire page instead of animating independently */

Why This Happens

The View Transitions API creates animated transitions between DOM states. It works by taking screenshots (snapshots) of the old and new states, then animating between them:

  • The browser must support it — View Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+. Firefox doesn’t support it yet. Without a feature check, calling startViewTransition in Firefox throws.
  • Same-document transitions need startViewTransition() — for SPAs, you must wrap DOM updates in document.startViewTransition(() => { ... }). Without this wrapper, React/Vue/Svelte state changes happen instantly with no transition.
  • Cross-document transitions need opt-in CSS — for MPAs (multi-page apps), both the old and new pages must include @view-transition { navigation: auto; } in their CSS. If either page is missing this rule, no transition happens.
  • view-transition-name must be unique per page — each element that should animate independently needs a unique view-transition-name. Duplicate names on the same page cause the transition to fail silently.

Fix 1: Same-Document Transitions (SPA)

// Basic transition — wrap DOM updates
function navigateTo(path: string) {
  // Feature detection
  if (!document.startViewTransition) {
    // Fallback — just update without animation
    updateContent(path);
    return;
  }

  document.startViewTransition(() => {
    updateContent(path);
  });
}

// React — transition between states
'use client';

import { useState, useCallback } from 'react';

function TabContent() {
  const [activeTab, setActiveTab] = useState('home');

  const switchTab = useCallback((tab: string) => {
    if (!document.startViewTransition) {
      setActiveTab(tab);
      return;
    }

    document.startViewTransition(() => {
      setActiveTab(tab);
    });
  }, []);

  return (
    <div>
      <nav>
        <button onClick={() => switchTab('home')}>Home</button>
        <button onClick={() => switchTab('about')}>About</button>
        <button onClick={() => switchTab('contact')}>Contact</button>
      </nav>

      <div>
        {activeTab === 'home' && <HomePage />}
        {activeTab === 'about' && <AboutPage />}
        {activeTab === 'contact' && <ContactPage />}
      </div>
    </div>
  );
}

// Async transitions — wait for data before transitioning
async function loadAndTransition(path: string) {
  // Fetch new data before starting transition
  const data = await fetch(`/api${path}`).then(r => r.json());

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

  // Wait for transition to complete
  await transition.finished;
  console.log('Transition complete');
}

Fix 2: Cross-Document Transitions (MPA)

/* Both pages must include this rule */
@view-transition {
  navigation: auto;
}

/* Default transition — full page cross-fade */
/* This happens automatically with the rule above */

/* Customize the transition */
::view-transition-old(root) {
  animation: fade-out 0.3s ease-in;
}

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

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

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}
<!-- Page A (list page) -->
<style>
  @view-transition { navigation: auto; }

  .card-image {
    view-transition-name: hero-image;
  }
</style>

<a href="/post/123">
  <img class="card-image" src="/images/post-123.jpg" />
  <h2>Post Title</h2>
</a>

<!-- Page B (detail page) -->
<style>
  @view-transition { navigation: auto; }

  .hero-image {
    view-transition-name: hero-image;
    /* Same name as the card image — creates a shared element transition */
  }
</style>

<img class="hero-image" src="/images/post-123.jpg" />
<h1>Post Title</h1>

Fix 3: Named Element Transitions

/* Each independently animated element needs a unique view-transition-name */

/* Header stays in place */
header {
  view-transition-name: header;
}

/* Sidebar stays in place */
.sidebar {
  view-transition-name: sidebar;
}

/* Main content area transitions */
main {
  view-transition-name: main-content;
}

/* Customize transition per named element */

/* Header — no animation (stays in place) */
::view-transition-old(header),
::view-transition-new(header) {
  animation: none;
}

/* Main content — slide */
::view-transition-old(main-content) {
  animation: slide-out-left 0.3s ease-in;
}

::view-transition-new(main-content) {
  animation: slide-in-right 0.3s ease-out;
}

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

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

/* Shared element — smooth morph between pages */
/* Same view-transition-name on both pages = shared element transition */
.product-image {
  view-transition-name: product-hero;
}

/* The browser automatically morphs between old and new positions/sizes */
::view-transition-old(product-hero),
::view-transition-new(product-hero) {
  animation-duration: 0.4s;
}

Fix 4: Dynamic view-transition-name (Lists)

// Each item in a list needs a unique view-transition-name
// Use inline styles since CSS can't generate unique names

function ProductGrid({ products }: { products: Product[] }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map(product => (
        <a
          key={product.id}
          href={`/products/${product.id}`}
          // Dynamic name per item
          style={{ viewTransitionName: `product-${product.id}` }}
        >
          <img
            src={product.image}
            alt={product.name}
            style={{ viewTransitionName: `product-image-${product.id}` }}
          />
          <h3>{product.name}</h3>
        </a>
      ))}
    </div>
  );
}

// Product detail page
function ProductDetail({ product }: { product: Product }) {
  return (
    <div>
      <img
        src={product.image}
        alt={product.name}
        style={{ viewTransitionName: `product-image-${product.id}` }}
        // Same name — browser morphs the image from grid position to full size
      />
      <h1 style={{ viewTransitionName: `product-${product.id}` }}>
        {product.name}
      </h1>
    </div>
  );
}

Fix 5: Astro Integration

---
// Astro has built-in View Transitions support
import { ViewTransitions } from 'astro:transitions';
---

<html>
  <head>
    <ViewTransitions />  <!-- Enables cross-page transitions -->
  </head>
  <body>
    <header transition:persist>
      <!-- Header persists across pages — no animation -->
    </header>

    <main transition:animate="slide">
      <!-- Content slides in/out -->
      <slot />
    </main>
  </body>
</html>
<!-- Shared element transitions in Astro -->
<a href={`/blog/${post.slug}`}>
  <img
    src={post.image}
    transition:name={`post-image-${post.slug}`}
  />
  <h2 transition:name={`post-title-${post.slug}`}>
    {post.title}
  </h2>
</a>

<!-- Detail page — same transition:name creates shared element transition -->
<img
  src={post.image}
  transition:name={`post-image-${post.slug}`}
/>
<h1 transition:name={`post-title-${post.slug}`}>
  {post.title}
</h1>

Fix 6: Next.js Integration

// Next.js doesn't have built-in view transitions yet
// Use the API manually with useRouter

'use client';

import { useRouter } from 'next/navigation';

function useViewTransitionRouter() {
  const router = useRouter();

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

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

  return { push };
}

// Usage
function ProductCard({ product }: { product: Product }) {
  const { push } = useViewTransitionRouter();

  return (
    <div
      onClick={() => push(`/products/${product.id}`)}
      style={{ cursor: 'pointer' }}
    >
      <img
        src={product.image}
        style={{ viewTransitionName: `product-${product.id}` }}
      />
      <h3>{product.name}</h3>
    </div>
  );
}
/* Global CSS for Next.js view transitions */
@view-transition {
  navigation: auto;
}

/* Keep header stable during transitions */
header {
  view-transition-name: header;
}

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

/* Page content transition */
::view-transition-old(root) {
  animation: fade-and-scale-out 0.25s ease-in forwards;
}

::view-transition-new(root) {
  animation: fade-and-scale-in 0.3s ease-out;
}

@keyframes fade-and-scale-out {
  to { opacity: 0; transform: scale(0.98); }
}

@keyframes fade-and-scale-in {
  from { opacity: 0; transform: scale(1.02); }
}

/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0s;
  }
}

Still Not Working?

No transition in Firefox — Firefox doesn’t support the View Transitions API yet. Always feature-detect: if (document.startViewTransition) { ... } else { fallback() }. Your app should work without transitions — they’re a progressive enhancement.

Cross-document transitions don’t play — both the origin and destination pages must have @view-transition { navigation: auto; } in their CSS. If either page is missing it, no transition occurs. Also, cross-document transitions only work for same-origin navigations.

Elements don’t animate independently — add view-transition-name to elements that should transition separately. Each name must be unique on the page. Duplicate names cause the entire transition to fall back to a simple cross-fade.

Transition plays but looks wrong — the default is a cross-fade. For slides, morphs, or custom animations, target the ::view-transition-old() and ::view-transition-new() pseudo-elements with CSS animations. Use the named versions (e.g., ::view-transition-old(hero)) for element-specific transitions.

For related animation issues, see Fix: GSAP Not Working and Fix: Framer Motion Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles