I'll never forget the first time I built a single-page application. The routing worked, but every page change felt jarring—content would just pop into existence. I spent weeks tweaking CSS animations, managing mounting/unmounting states, coordinating timings. Then Chrome shipped the View Transitions API, and I rebuilt the entire transition system in an afternoon. Native, smooth, and it worked with regular link clicks. No JavaScript framework required.
The View Transitions API is a fundamental shift in how we think about page navigation. It's not just about making things pretty—it's about building continuity, reducing cognitive load, and creating web experiences that feel as fluid as native apps. Let's explore how to harness this powerful API for production applications.
What is the View Transitions API?
The View Transitions API allows you to create smooth, animated transitions between different DOM states—whether that's a page navigation, a component update, or a layout change. The browser automatically captures before and after snapshots, then morphs between them with customizable animations.
Browser Support (as of November 2025):
- ✅ Chrome 111+ (Stable)
- ✅ Edge 111+
- ✅ Opera 97+
- ⚠️ Safari 18+ (Behind flag)
- ⚠️ Firefox (In development)
Key Features:
- Zero JavaScript animations - Pure CSS control
- Automatic element matching - Browser handles morphing
- Accessibility built-in - Respects
prefers-reduced-motion - Progressive enhancement - Graceful degradation for unsupported browsers
- Framework agnostic - Works with vanilla JS, React, Vue, etc.
Basic Concept: The Transition Lifecycle
// The fundamental API
document.startViewTransition(() => {
// Make DOM changes here
updateDOMState();
});
What happens under the hood:
- Capture: Browser screenshots the current state
- Execute: Your callback runs, updating the DOM
- Capture: Browser screenshots the new state
- Animate: Browser morphs from old to new with CSS animations
/* Default transition (can be customized) */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
animation-timing-function: ease;
}
Pattern 1: Simple Same-Document Transitions
Let's start with a basic tab switcher:
<!-- index.html -->
<div class="tabs">
<button data-tab="profile">Profile</button>
<button data-tab="settings">Settings</button>
<button data-tab="notifications">Notifications</button>
</div>
<div class="tab-content">
<div id="profile" class="tab-panel">
<h2>Profile</h2>
<p>Your profile information...</p>
</div>
<div id="settings" class="tab-panel" hidden>
<h2>Settings</h2>
<p>Adjust your preferences...</p>
</div>
<div id="notifications" class="tab-panel" hidden>
<h2>Notifications</h2>
<p>Your notification settings...</p>
</div>
</div>
// app.js
document.querySelectorAll('[data-tab]').forEach(button => {
button.addEventListener('click', () => {
const targetId = button.dataset.tab;
// Check for browser support
if (!document.startViewTransition) {
// Fallback for unsupported browsers
switchTabImmediate(targetId);
return;
}
// Use View Transitions API
document.startViewTransition(() => {
switchTabImmediate(targetId);
});
});
});
function switchTabImmediate(targetId) {
// Hide all panels
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.hidden = true;
});
// Show target panel
document.getElementById(targetId).hidden = false;
// Update active button
document.querySelectorAll('[data-tab]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === targetId);
});
}
/* style.css */
/* Customize the transition */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Fade out old content */
::view-transition-old(root) {
animation-name: fade-out;
}
/* Fade in new content */
::view-transition-new(root) {
animation-name: fade-in;
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
}
Key Points:
- Always check for API support with feature detection
- Provide fallback for unsupported browsers
- The transition is automatic—browser handles the animation
Pattern 2: Cross-Document View Transitions
This is where it gets powerful. Multi-page applications can have SPA-like transitions:
<!-- Enable cross-document transitions in meta tag -->
<meta name="view-transition" content="same-origin" />
/* styles.css - Applies to all pages */
/* Customize page navigation transitions */
@view-transition {
navigation: auto; /* Enable for all navigations */
}
/* Slide animation for navigation */
::view-transition-old(root) {
animation: slide-out-left 0.3s ease-out;
}
::view-transition-new(root) {
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;
}
}
/* Respect user preferences */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
}
Now regular <a> links automatically get smooth transitions—no JavaScript needed!
<!-- page1.html -->
<nav>
<a href="/page2.html">Go to Page 2</a>
</nav>
<!-- page2.html -->
<nav>
<a href="/page1.html">Back to Page 1</a>
</nav>
Watch Out: Cross-document transitions only work for same-origin navigations. External links won't transition.
Pattern 3: Named View Transitions
The real power comes from naming elements to create targeted transitions:
<div class="gallery">
<div class="card" style="view-transition-name: card-1">
<img src="image1.jpg" alt="Image 1" />
<h3>Title 1</h3>
</div>
<div class="card" style="view-transition-name: card-2">
<img src="image2.jpg" alt="Image 2" />
<h3>Title 2</h3>
</div>
</div>
<div class="modal" hidden>
<div class="modal-content">
<img style="view-transition-name: featured-image" src="" alt="" />
<h2 style="view-transition-name: featured-title"></h2>
</div>
</div>
function openModal(cardId) {
const card = document.querySelector(`[style*="card-${cardId}"]`);
const modal = document.querySelector('.modal');
// Transfer view-transition-name before transition
const img = card.querySelector('img');
const title = card.querySelector('h3');
document.startViewTransition(() => {
// Copy content to modal
modal.querySelector('img').src = img.src;
modal.querySelector('h2').textContent = title.textContent;
// Show modal
modal.hidden = false;
// Update view-transition-names
img.style.viewTransitionName = 'featured-image';
title.style.viewTransitionName = 'featured-title';
});
}
/* Custom animation for modal opening */
::view-transition-old(featured-image),
::view-transition-new(featured-image) {
/* Image morphs smoothly from card to modal */
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(featured-title),
::view-transition-new(featured-title) {
/* Title moves and scales */
mix-blend-mode: normal;
animation-duration: 0.5s;
}
The browser automatically morphs elements with matching view-transition-name values!
Pattern 4: List Reordering
Perfect for sortable lists, drag-and-drop, or filtering:
<ul class="task-list">
<li style="view-transition-name: task-1">Task 1</li>
<li style="view-transition-name: task-2">Task 2</li>
<li style="view-transition-name: task-3">Task 3</li>
</ul>
function sortTasks(order) {
const list = document.querySelector('.task-list');
document.startViewTransition(() => {
// Reorder DOM elements
order.forEach(id => {
const task = document.querySelector(`[style*="task-${id}"]`);
list.appendChild(task);
});
});
}
// Example: Sort by completion
sortTasks([3, 1, 2]); // Tasks smoothly rearrange!
Key Points:
- Each element needs a unique
view-transition-name - Browser handles position interpolation automatically
- Works with any DOM manipulation (sort, filter, add, remove)
Pattern 5: Next.js Integration
For Next.js App Router with client-side navigation:
// hooks/useViewTransition.ts
'use client';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
export function useViewTransitionRouter() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
function push(href: string) {
if (!document.startViewTransition) {
router.push(href);
return;
}
document.startViewTransition(() => {
startTransition(() => {
router.push(href);
});
});
}
return { push, isPending };
}
// components/NavLink.tsx
'use client';
import Link from 'next/link';
import { useViewTransitionRouter } from '@/hooks/useViewTransition';
export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const router = useViewTransitionRouter();
return (
<Link
href={href}
onClick={(e) => {
e.preventDefault();
router.push(href);
}}
>
{children}
</Link>
);
}
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<meta name="view-transition" content="same-origin" />
</head>
<body>
<nav>
<NavLink href="/">Home</NavLink>
<NavLink href="/about">About</NavLink>
<NavLink href="/contact">Contact</NavLink>
</nav>
{children}
</body>
</html>
);
}
/* app/globals.css */
@view-transition {
navigation: auto;
}
/* Customize page transitions */
::view-transition-old(root) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-in;
}
Pattern 6: Shared Element Transitions
Create cinematic transitions between pages with shared elements:
// app/products/page.tsx
export default function ProductsPage() {
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<Link
key={product.id}
href={`/products/${product.id}`}
className="card"
>
<img
src={product.image}
alt={product.name}
style={{ viewTransitionName: `product-${product.id}` }}
/>
<h3 style={{ viewTransitionName: `title-${product.id}` }}>
{product.name}
</h3>
</Link>
))}
</div>
);
}
// app/products/[id]/page.tsx
export default function ProductPage({ params }: { params: { id: string } }) {
const product = getProduct(params.id);
return (
<div className="product-detail">
<img
src={product.image}
alt={product.name}
style={{ viewTransitionName: `product-${params.id}` }}
className="large-image"
/>
<h1 style={{ viewTransitionName: `title-${params.id}` }}>
{product.name}
</h1>
<p>{product.description}</p>
</div>
);
}
The image and title smoothly morph from grid to detail view!
Pattern 7: Custom Animation Types
Create different transition styles for different scenarios:
/* Different transitions for different elements */
/* Hero images: Scale and fade */
::view-transition-old(hero),
::view-transition-new(hero) {
animation-duration: 0.6s;
}
::view-transition-old(hero) {
animation-name: scale-down;
}
::view-transition-new(hero) {
animation-name: scale-up;
}
@keyframes scale-down {
to {
transform: scale(0.9);
opacity: 0;
}
}
@keyframes scale-up {
from {
transform: scale(0.9);
opacity: 0;
}
}
/* Sidebar: Slide from side */
::view-transition-old(sidebar) {
animation: slide-out-left 0.3s ease-out;
}
::view-transition-new(sidebar) {
animation: slide-in-left 0.3s ease-out;
}
/* Content: Fade only */
::view-transition-old(content),
::view-transition-new(content) {
animation-duration: 0.2s;
}
Progressive Enhancement Strategy
Always provide fallbacks:
// Utility function with feature detection
function transitionHelper(updateCallback) {
if (
!document.startViewTransition ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
// No transition support or user prefers reduced motion
updateCallback();
return Promise.resolve();
}
// Use view transitions
const transition = document.startViewTransition(updateCallback);
return transition.finished;
}
// Usage
transitionHelper(() => {
// Update DOM
element.classList.toggle('active');
}).then(() => {
// After transition completes
console.log('Transition finished!');
});
Performance Considerations
Best Practices:
- Limit transition scope: Don't transition entire pages if only a section changes
- Use
will-changecarefully: Only for elements actively transitioning - Optimize images: Transition smooth gradients, not noisy photos
- Keep transitions short: 200-400ms is ideal
- Test on low-end devices: Ensure 60fps on mobile
/* Optimize for performance */
.transitioning-element {
will-change: transform, opacity;
}
/* After transition */
.transitioning-element:not(.active) {
will-change: auto; /* Remove hint */
}
Debugging View Transitions
Chrome DevTools has built-in support:
- Enable "Capture screenshots" in Performance panel
- Slow down animations for inspection:
/* Development only */
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 3s !important; /* Slow motion */
}
- Log transition lifecycle:
const transition = document.startViewTransition(() => {
updateDOM();
});
transition.ready.then(() => console.log('Transition ready'));
transition.finished.then(() => console.log('Transition finished'));
transition.updateCallbackDone.then(() => console.log('Callback done'));
Common Pitfalls
❌ Duplicate view-transition-name
<!-- DON'T: Two elements with same name -->
<div style="view-transition-name: card">Card 1</div>
<div style="view-transition-name: card">Card 2</div> <!-- ❌ Error! -->
Each view-transition-name must be unique per page.
❌ Forgetting to Update Names
// DON'T: Old names persist
document.startViewTransition(() => {
element.hidden = true; // Element still has transition name!
});
// DO: Clean up names
document.startViewTransition(() => {
element.style.viewTransitionName = 'none';
element.hidden = true;
});
❌ Complex DOM Changes
// DON'T: Too many changes at once
document.startViewTransition(() => {
// Replacing entire page content
document.body.innerHTML = newContent; // ❌ Janky!
});
// DO: Targeted updates
document.startViewTransition(() => {
// Only update what changed
contentDiv.innerHTML = newContent; // ✅ Smooth
});
Production Checklist
Before shipping view transitions:
- ✅ Feature detection with fallbacks
- ✅
prefers-reduced-motionrespect - ✅ Unique
view-transition-namefor shared elements - ✅ Testing on Safari (with polyfill if needed)
- ✅ Performance testing on low-end devices
- ✅ Analytics tracking for transition usage
- ✅ Error boundaries for failed transitions
- ✅ Documentation for team on naming conventions
Real-World Example: Blog with Transitions
// app/blog/page.tsx
export default function BlogPage() {
return (
<div className="blog-grid">
{posts.map(post => (
<article key={post.id}>
<Link href={`/blog/${post.slug}`}>
<img
src={post.coverImage}
alt={post.title}
style={{ viewTransitionName: `cover-${post.id}` }}
/>
<h2 style={{ viewTransitionName: `title-${post.id}` }}>
{post.title}
</h2>
<p>{post.excerpt}</p>
</Link>
</article>
))}
</div>
);
}
// app/blog/[slug]/page.tsx
export default function PostPage({ params }: { params: { slug: string } }) {
const post = getPost(params.slug);
return (
<article className="post">
<img
src={post.coverImage}
alt={post.title}
style={{ viewTransitionName: `cover-${post.id}` }}
className="cover-image"
/>
<h1 style={{ viewTransitionName: `title-${post.id}` }}>
{post.title}
</h1>
<div className="content">
<MDXRemote source={post.content} />
</div>
</article>
);
}
/* Customize blog transitions */
::view-transition-old(root) {
animation: fade-out 0.2s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-in;
}
/* Cover images morph smoothly */
[style*="view-transition-name: cover"] {
view-transition-class: cover-image;
}
::view-transition-old(cover-image),
::view-transition-new(cover-image) {
height: 100%;
object-fit: cover;
animation-duration: 0.5s;
}
Summary
The View Transitions API brings native, performant page transitions to the web platform:
Key Takeaways:
- Progressive enhancement - Always provide fallbacks
- Named transitions - Match elements with
view-transition-name - Pure CSS - No JavaScript animation management needed
- Accessibility first - Respect
prefers-reduced-motion - Cross-document support - MPA transitions without frameworks
When to Use:
- ✅ Page navigations (MPA or SPA)
- ✅ Modal/dialog opening
- ✅ List reordering
- ✅ Image galleries
- ✅ Tab switching
- ✅ Accordion menus
When to Skip:
- ❌ Continuous animations (use CSS animations)
- ❌ Complex multi-step transitions (use GSAP/Framer Motion)
- ❌ Need support for older browsers (use polyfill or fallback)
Frequently Asked Questions
Which browsers support the View Transitions API?
Chrome/Edge 111+, Safari 18+ (iOS 18 released September 2024), and Opera 97+ fully support View Transitions. Firefox is implementing it with experimental support in Nightly builds. This represents roughly 70-75% of global browser market share as of 2025. Always use feature detection and provide fallbacks for unsupported browsers.
Can I use View Transitions with React Router or Next.js?
Yes! React Router v6.4+ has built-in support via unstable_useViewTransitionState. For Next.js App Router, wrap navigation in startViewTransition() manually. Both approaches work, but require careful state management to avoid hydration mismatches. Always test with concurrent features enabled.
How do View Transitions impact performance?
View Transitions can improve perceived performance by creating smooth animations instead of jarring content shifts. However, they do add overhead: the browser takes screenshots, manages animation layers, and runs interpolation. For most applications, this overhead is minimal (1-5ms). Avoid animating large images or complex DOMs—keep transitions focused on hero elements.
Can I animate between different layouts (e.g., grid to list)?
Yes, this is one of View Transitions' superpowers. Elements with matching view-transition-name will morph between completely different positions, sizes, and parent containers. The browser calculates the optimal animation path automatically. This works for grid-to-list, sidebar-to-modal, thumbnail-to-fullscreen, and any other layout transformation.
How do I prevent flash of unstyled content (FOUC) during transitions?
Ensure new content is fully loaded before starting the transition. Use Promise.all() to wait for images, data, and stylesheets. In SPAs, preload the next page's critical resources. For server-rendered pages, use loading indicators until the full DOM is ready. The View Transitions API only animates—it doesn't handle resource loading.
Can I use View Transitions with CSS-in-JS libraries?
Yes, but with caveats. Libraries like styled-components generate dynamic class names that can break view-transition-name matching. Use stable identifiers (IDs or data attributes) rather than class names for transition names. Alternatively, generate consistent class names via component IDs or use inline styles for view-transition-name.
How do I test View Transitions in automated tests?
Most testing libraries don't support View Transitions yet. For E2E tests (Playwright, Cypress), run in Chromium browsers and add waits after navigation: await page.waitForTimeout(300) to let transitions complete. For unit tests, mock document.startViewTransition to execute callbacks immediately. Visual regression tests (Percy, Chromatic) can capture mid-transition screenshots.
Are View Transitions accessible?
By default, yes—View Transitions respect prefers-reduced-motion. However, you must ensure keyboard navigation still works during transitions (focus management) and that screen readers announce page changes correctly. Test with assistive technologies and provide skip-transition options for users who need them.
The View Transitions API is production-ready today in Chromium browsers, with wider support coming soon. Start with progressive enhancement, and your users on supported browsers will get a dramatically improved experience—while others get a solid fallback.