Fix: View Transitions API Not Working — No Animation Between Pages, Cross-Document Transitions Failing, or Fallback Missing
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 transitionOr 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
startViewTransitionin Firefox throws. - Same-document transitions need
startViewTransition()— for SPAs, you must wrap DOM updates indocument.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-namemust be unique per page — each element that should animate independently needs a uniqueview-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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.
Fix: Lottie Not Working — Animation Not Playing, File Not Loading, or React Component Blank
How to fix Lottie animation issues — lottie-react and lottie-web setup, JSON animation loading, playback control, interactivity, lazy loading, and performance optimization.
Fix: Panda CSS Not Working — Styles Not Applying, Tokens Not Resolving, or Build Errors
How to fix Panda CSS issues — PostCSS setup, panda.config.ts token system, recipe and pattern definitions, conditional styles, responsive design, and integration with Next.js and Vite.