Fix: View Transitions API Not Working — No Animation Between Pages, Cross-Document Transitions Failing, or Fallback Missing
Part of: React & Frontend Errors
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 API has two distinct modes that fail in different ways. Same-document transitions wrap an imperative DOM update — they fire only when you explicitly call document.startViewTransition(). Cross-document transitions fire on real navigations, but only when both pages opt in through CSS. Many teams ship the same-document path, then assume the cross-document path “just works” because the API name is the same. It does not. The opt-in is per-document, per-direction, and same-origin only.
- 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.
A third failure mode rarely appears in dev and bites in production: the transition fires but the snapshot is wrong because the new page paints before the API captures it. This usually happens when fonts swap, images decode late, or layout shifts during navigation. The transition looks “glitchy” only for users on cold caches or slow networks — exactly the users you cannot reproduce on a fast laptop. Treat it as a production signal, not a dev bug.
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;
}
}Production Incident: The Safari Segment Regression
A common production failure mode looks like this. You ship the rewrite, smoke-test in Chrome and Edge on your laptop, and roll out. Two days later, support tickets arrive from iPad users on iOS 17 and Mac users on macOS Sonoma running Safari 17. The cards in your product grid no longer animate to the detail page. Instead, they hard-cut, and the page feels “broken.” Your error tracker is silent because nothing throws — the feature degrades, it does not crash.
The blast radius is a browser segment, not a code path. Roughly 18-22% of mobile web traffic on consumer sites is Safari. If you ship a redesign whose perceived quality depends on the transition, a quarter of your audience now experiences a worse version than they did before. Conversion drops on Safari while it stays flat on Chrome — and if you only watch site-wide metrics, you will miss it for a week.
Three guardrails prevent this incident. First, feature-detect every entry point: if (!document.startViewTransition) { updateContent(path); return; }. Second, segment your real-user metrics by userAgent family so a regression in one engine surfaces immediately. Third, ship a non-transition fallback that still feels fast — a tasteful 150ms CSS cross-fade on the destination root is enough to hide the cut. Treat the transition as progressive enhancement, not as a feature.
The same pattern applies to Firefox users and to corporate Chrome installs pinned to versions below 111. Anything older than April 2023 will see your fallback path. Make sure the fallback is the version you would be willing to ship on its own.
A second class of cross-document incident comes from infrastructure. View transitions only fire on same-origin navigations. If your CDN serves the marketing site from example.com and the app from app.example.com, navigation between them never transitions. Worse, if you A/B test by routing some users through a different origin or a different edge worker that strips @view-transition CSS, transitions silently disappear for the test cohort. The fix is to treat the @view-transition opt-in as a hard contract: include it in your default CSS bundle for every page in the navigation graph, and add a CI check that asserts the rule is present in the production HTML output of every entry route.
The third pattern is the partial-render regression. Cross-document transitions snapshot the new document at first paint. If your page does aggressive code-splitting and the above-the-fold content arrives in a lazy chunk, the snapshot may capture the loading skeleton instead of the real content. The user sees a transition into an empty page, then a flash as content arrives. From a metrics standpoint, your Largest Contentful Paint is unchanged but perceived performance is worse. Fix it by inlining critical content into the initial HTML for routes that participate in transitions, and accept that view transitions and aggressive route splitting have a real tension worth measuring.
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.
Snapshot captures the wrong frame on slow networks — the browser captures the new state the moment your callback resolves. If fonts, images, or async content paint after that, the snapshot is stale and the animation looks glitchy. Await critical assets inside the transition callback: document.startViewTransition(async () => { await Promise.all([navigate(path), document.fonts.ready, preloadHeroImage()]); }). Use the update callback’s returned promise to delay the capture until layout is stable.
Transition fires but immediately interrupts itself — calling startViewTransition again before the previous one finishes cancels the first one. This shows up in apps where users click multiple links quickly. Guard the call with a single in-flight transition, or use transition.skipTransition() deliberately when you want the new one to win. Watch for double-fired router events in React strict mode during development.
Cross-origin redirects kill the transition — cross-document transitions only work for same-origin navigations. If your auth flow redirects through a third-party domain and back, the transition will not fire on the return trip. Either keep the redirect same-origin or accept that the post-auth landing has no transition.
For related animation issues, see Fix: GSAP Not Working, Fix: Framer Motion Not Working, Fix: Astro Content Collections Not Working, and Fix: Next.js Hydration Failed.
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.