Introduction
Page transitions have long been the domain of JavaScript animation libraries—Barba.js, Swup, GSAP, and Framer Motion all exist because browsers never offered a native way to animate between two states of a page. Developers had to manually clone DOM elements, position them absolutely, animate them across the viewport, then clean up. The View Transitions API changes this by providing a browser-native mechanism to capture snapshots of the old and new DOM states, then animate between them with customizable CSS transitions.
This matters because smooth page transitions reduce cognitive load and make applications feel responsive. When a user clicks a product card and the image smoothly expands into a detail view, or when navigating between pages causes content to crossfade elegantly, the experience feels polished and intentional. The View Transitions API makes this achievable without complex JavaScript orchestration.
In this guide, we'll explore how the View Transitions API works for both single-page applications (SPA) and multi-page applications (MPA), cover the snapshot mechanism, customization options, and real-world implementation patterns.
Understanding View Transitions: Core Concepts
How View Transitions Work
The View Transitions API captures a visual snapshot of the current DOM state (the "old" snapshot), lets you update the DOM, captures a snapshot of the new state (the "new" snapshot), and then animates between them. The browser handles the snapshot capture, positioning, and animation lifecycle.
The core flow:
- You call
document.startViewTransition(() => updateDOM()). - The browser captures a snapshot of the current page.
- Your callback runs, updating the DOM.
- The browser captures a snapshot of the new page.
- Crossfade animation plays: old snapshot fades out, new snapshot fades in.
- Transition finishes, snapshots are removed.
The view-transition-name Property
Individual elements can be given a view-transition-name to participate in the transition independently. When an element has the same view-transition-name in both the old and new snapshots, the browser creates a separate animation for that element—allowing it to morph position, size, and style independently from the rest of the page.
.hero-image {
view-transition-name: hero;
}.detail-image {
view-transition-name: hero;
}When navigating between a list view (with .hero-image) and a detail view (with .detail-image), the image smoothly morphs between its two positions and sizes.
SPA vs MPA View Transitions
The API works in two modes:
- SPA: You call
document.startViewTransition()manually in JavaScript. Both snapshots are taken from the same document. - MPA (Cross-document): You opt in via CSS with
@view-transition { navigation: auto; }. The browser handles the transition automatically during same-origin navigations.
Architecture and Design Patterns
Snapshot Pseudo-Element Tree
When a view transition starts, the browser creates a pseudo-element tree:
::view-transition
├── ::view-transition-group(root)
│ └── ::view-transition-image-pair(root)
│ ├── ::view-transition-old(root)
│ └── ::view-transition-new(root)
├── ::view-transition-group(hero)
│ └── ::view-transition-image-pair(hero)
│ ├── ::view-transition-old(hero)
│ └── ::view-transition-new(hero)
Each named element gets its own group with old/new image pairs. You style these pseudo-elements to customize the animation.
Default Animation
The default transition is a crossfade:
::view-transition-old(root) {
animation: fade-out 0.25s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.25s ease-in;
}You can replace these with any CSS animation—slide, scale, rotate, or custom keyframes.
The ready Promise
startViewTransition() returns an object with two promises:
updateCallbackDone: Resolves when your DOM update function completes.finished: Resolves when the transition animation completes and the pseudo-elements are removed.
const transition = document.startViewTransition(() => {
updatePage(newContent);
});
transition.finished.then(() => {
console.log('Transition complete');
});Step-by-Step Implementation
Basic SPA View Transition
// Trigger transition on navigation
async function navigateTo(url) {
const transition = document.startViewTransition(async () => {
const response = await fetch(url);
const html = await response.text();
document.getElementById('content').innerHTML = html;
});
await transition.finished;
}/* Customize the crossfade duration */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}Morphing a Hero Image Between Pages
/* List page: the product card image */
.product-card img {
view-transition-name: product-hero;
width: 200px;
height: 150px;
object-fit: cover;
}
/* Detail page: the full-width hero image */
.detail-hero {
view-transition-name: product-hero;
width: 100%;
height: 400px;
object-fit: cover;
}async function showProductDetail(productId) {
document.startViewTransition(async () => {
const detail = await fetchProduct(productId);
renderDetailPage(detail);
});
}The image morphs from its card position to the full-width hero position with a smooth size and position animation.
Custom Keyframe Animations
::view-transition-old(root) {
animation: slide-out-left 0.3s ease-in-out;
}
::view-transition-new(root) {
animation: slide-in-right 0.3s ease-in-out;
}
@keyframes slide-out-left {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100px); opacity: 0; }
}
@keyframes slide-in-right {
from { transform: translateX(100px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}Cross-Document (MPA) Transitions
/* In your CSS—opt in to MPA view transitions */
@view-transition {
navigation: auto;
}
/* Customize per-page transitions */
@view-transition {
navigation: auto;
}
::view-transition-old(root) {
animation: fade-out 0.2s;
}
::view-transition-new(root) {
animation: fade-in 0.2s;
}For same-origin navigations, the browser automatically captures snapshots and runs the transition. No JavaScript required.
Disabling Transitions Conditionally
document.startViewTransition(() => {
if (shouldSkipAnimation) {
document.startViewTransition.skipTransition();
}
updateDOM();
});Or use @media (prefers-reduced-motion: reduce) to disable via CSS:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Real-World Use Cases
Image Gallery to Detail View
When a user clicks a thumbnail in a gallery, the image expands to fill the detail view. The view-transition-name on both the thumbnail and the full-size image ensures the browser morphs them smoothly, even though the DOM structure changes completely.
Tab Switching in SPAs
Tab content transitions become trivial—old tab content fades out while new content fades in, with individual elements morphing between their positions in the two tab layouts.
Multi-Step Forms
Wizard-style forms can transition between steps with slide animations. The form container stays in place while the step content slides left/right.
Theme Switching
Dark mode toggles can use view transitions to smoothly crossfade between the two themes, rather than an instant color swap.
Best Practices for Production
-
Use
view-transition-namesparingly — Each named element creates a separate compositing layer. Naming dozens of elements can cause GPU memory pressure and layout complexity. -
Keep transition durations short — 200ms–400ms is ideal. Longer transitions feel sluggish and delay user interaction.
-
Handle the
finishedpromise — Don't assume the transition is complete immediately. Useawait transition.finishedbefore triggering follow-up actions. -
Provide reduced-motion fallbacks — Wrap animations in
@media (prefers-reduced-motion: no-preference)to respect user preferences. -
Test with slow network conditions — If your DOM update involves fetching data, the transition waits for the fetch. Under slow networks, this can feel like a delay before the animation starts.
-
Use
updateCallbackDonefor loading states — Show a loading indicator after the old snapshot is captured but before the new content arrives. -
Avoid
view-transition-nameon scrollable containers — Snapshots capture the visible viewport of the element, not its full scrollable content.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Duplicate view-transition-name values | Transition fails silently | Ensure each name is unique per snapshot |
Forgetting view-transition-name on morph targets | No morph animation, just crossfade | Add matching names to old and new elements |
| Not handling reduced motion | Accessibility violation | Add @media (prefers-reduced-motion) overrides |
| DOM update throws error | Transition stuck in old state | Wrap update in try/catch, call skipTransition() on error |
| MPA transitions not working | Missing @view-transition { navigation: auto; } | Add the opt-in CSS rule |
Performance Optimization
View transitions are GPU-accelerated by default—the browser captures snapshots as images and composites them. However, you can optimize:
/* Promote named elements to their own layer */
[style*="view-transition-name"] {
will-change: transform, opacity;
}For complex pages with many named elements, consider batching transitions:
// Disable transitions for bulk updates
document.startViewTransition.skipTransition();
bulkUpdateDOM();Comparison with Alternatives
| Feature | View Transitions API | Framer Motion | GSAP | Barba.js |
|---|---|---|---|---|
| Browser-native | ✅ | ❌ | ❌ | ❌ |
| MPA support | ✅ (opt-in CSS) | ❌ | ❌ | ✅ |
| SPA support | ✅ | ✅ | ✅ | ✅ |
| Morph animations | ✅ (via names) | ✅ | ✅ | ❌ |
| JavaScript required | Minimal | Yes | Yes | Yes |
| Bundle size | 0 KB | ~30 KB | ~25 KB | ~8 KB |
| Browser support | Chrome 111+, Safari 18+ | All | All | All |
Advanced Patterns
Per-Page Transition Customization
/* Different transitions for different page types */
@view-transition {
navigation: auto;
}
.home-to-detail::view-transition-old(root) {
animation: slide-out-left 0.3s;
}
.home-to-detail::view-transition-new(root) {
animation: slide-in-right 0.3s;
}
.detail-to-home::view-transition-old(root) {
animation: slide-out-right 0.3s;
}
.detail-to-home::view-transition-new(root) {
animation: slide-in-left 0.3s;
}Shared Element Transitions with Layout Changes
/* Card view */
.card-title {
view-transition-name: item-title;
font-size: 1rem;
}
/* Detail view */
.detail-title {
view-transition-name: item-title;
font-size: 2rem;
}The title morphs in both position and font size between the two views.
Testing Strategies
// Playwright test for view transition
test('product card triggers view transition', async ({ page }) => {
await page.goto('/products');
// Click a product card
await page.click('.product-card:first-child');
// Wait for transition to complete
await page.waitForTimeout(400);
// Verify detail page is shown
await expect(page.locator('.detail-hero')).toBeVisible();
});
// Test reduced motion
test('respects reduced motion preference', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/products');
const start = Date.now();
await page.click('.product-card:first-child');
await page.waitForSelector('.detail-hero');
const elapsed = Date.now() - start;
// Should complete quickly without animation
expect(elapsed).toBeLessThan(200);
});Future Outlook
The View Transitions API is rapidly evolving. Chrome shipped SPA view transitions in Chrome 111 (March 2023) and cross-document transitions in Chrome 126 (June 2024). Safari added support in Safari 18 (September 2024). Firefox has it behind a flag.
Future enhancements include:
- View Transition Types: Conditional animations based on navigation direction (forward vs back).
- Custom transition triggers: CSS-only triggers beyond navigation, like scroll-based transitions.
- Integration with the Navigation API: Programmatic control over transition lifecycle.
Accessibility Considerations
View transitions must respect user preferences for reduced motion. The CSS prefers-reduced-motion media query allows you to disable or simplify animations for users who have indicated they prefer less motion. Always provide a fallback that works without animations. Screen readers and other assistive technologies should be tested with view transitions to ensure that content updates are announced correctly. The View Transitions API handles focus management during transitions, but you should verify that focus moves to the appropriate element after the transition completes. Keyboard navigation must continue to work seamlessly throughout the transition, and interactive elements must not be temporarily unreachable during the animation.
Cross-Browser Support and Polyfills
Browser support for View Transitions has expanded rapidly, with Chrome, Edge, and Safari supporting the API natively. Firefox is actively implementing support. For browsers that do not support View Transitions, the API degrades gracefully: the page navigates normally without animation, and no functionality is lost. If you need consistent animation across all browsers, the view-transitions polyfill provides a JavaScript-based fallback that implements similar functionality using CSS animations and DOM manipulation. Test your application with and without View Transitions to ensure the fallback experience is acceptable. Progressive enhancement means you can adopt View Transitions today without worrying about breaking the experience for users on older browsers. The polyfill adds approximately ten kilobytes to your bundle and should be loaded conditionally only for browsers that need it.
Framework Integration
The View Transitions API integrates with major frameworks through their routing systems. In Next.js, the experimental.viewTransition option enables automatic transitions between pages. In Astro, the ViewTransitions component handles both SPA and MPA transitions. In SvelteKit, the onNavigate lifecycle hook provides programmatic control over transitions:
// Next.js 15 — automatic view transitions
// next.config.js
const nextConfig = {
experimental: {
viewTransition: true,
},
};
// SvelteKit — programmatic control
// src/routes/+layout.svelte
import { onNavigate } from '$app/navigation';
onNavigate((navigation) => {
if (!document.startViewTransition) return;
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
});
});For React applications without framework support, wrap your router transitions in startViewTransition:
function navigate(url) {
if (document.startViewTransition) {
document.startViewTransition(async () => {
// Update your router state
router.push(url);
// Wait for the new content to render
await new Promise(resolve => setTimeout(resolve, 0));
});
} else {
router.push(url);
}
}Performance Considerations
View transitions are GPU-accelerated by default. The browser captures screenshots of the old and new states, then animates between them using CSS transforms and opacity. This means the animation runs on the compositor thread and doesn't block the main thread. However, capturing screenshots has a cost — for very large pages with complex layouts, the capture step can take 50-100ms. Keep your pages reasonably sized and avoid transitions on pages with thousands of DOM nodes.
The view-transition-name property must be unique within the page. Duplicate names cause the browser to log a warning and skip the transition for those elements. Use meaningful names that describe the element's role (e.g., view-transition-name: hero-image) rather than generated IDs.
Debugging View Transitions
Chrome DevTools provides built-in support for debugging view transitions. The Animations panel shows all active view transitions, including their timing, easing, and keyframes. You can slow down animations to 25% speed to inspect the transition in detail. The Elements panel highlights elements with view-transition-name and shows their captured screenshots. If a transition isn't working as expected, check for duplicate view-transition-name values in the Console — the browser logs warnings when names conflict.
The Performance panel records view transitions as part of the timeline, showing exactly how long the capture, animation, and restore phases take. Use this to identify performance bottlenecks in your transitions. If the capture phase takes too long, consider reducing the number of elements with view-transition-name or simplifying the page layout during the transition.
Common Transition Patterns
Several transition patterns have emerged as best practices for different types of navigation. The shared element transition moves a specific element (like a product image) from its position on the list page to a new position on the detail page while other content crossfades. This pattern draws the user's eye to the connection between the two views.
The slide transition moves the entire old page out while sliding the new page in, mimicking native mobile navigation. This works well for hierarchical navigation where moving forward slides left and going back slides right:
@keyframes slide-from-right {
from { transform: translateX(100%); }
}
@keyframes slide-to-left {
to { transform: translateX(-100%); }
}
::view-transition-old(root) {
animation: slide-to-left 0.3s ease-in-out;
}
::view-transition-new(root) {
animation: slide-from-right 0.3s ease-in-out;
}The morph transition changes the shape and position of an element simultaneously, like expanding a small card into a full-screen hero image. This requires careful coordination of object-fit, border-radius, and layout properties in the keyframe animations. Test morph transitions thoroughly because animating layout properties like width and height can cause visual artifacts if the aspect ratios differ significantly between the old and new states.
The crossfade transition is the simplest and most reliable pattern — the old content fades out while the new content fades in. This works well for tab switching, filter changes, and any navigation where no specific element needs to maintain continuity. Crossfade transitions are also the safest choice for responsive designs because they don't depend on element positions that may change across breakpoints.
Conclusion
The View Transitions API brings native page transitions to the web platform, eliminating the need for complex JavaScript animation orchestration. Whether you're building a SPA with manual transitions or an MPA with automatic cross-document transitions, the API provides a clean, performant mechanism for animating between page states.
Key takeaways:
- Use
document.startViewTransition()for SPA transitions and@view-transition { navigation: auto; }for MPA transitions. - Assign
view-transition-nameto elements that should morph independently between states. - Customize animations via
::view-transition-old()and::view-transition-new()pseudo-elements. - Always provide reduced-motion fallbacks.
- Keep transitions under 400ms for responsive feel.
Start by adding view transitions to one navigation flow in your application—you'll immediately see the UX improvement with minimal code.