The View Transitions API is a powerful web platform feature that enables smooth, app-like transitions between page states or documents. This comprehensive guide covers everything you need to know about implementing native page transitions.
What is the View Transitions API?
The View Transitions API provides a mechanism for easily creating animated transitions between different DOM states while also updating the DOM contents in a single step.
// Basic view transition
async function navigateTo(url) {
if (!document.startViewTransition) {
window.location = url;
return;
}
document.startViewTransition(() => {
window.location = url;
});
}
Key Features
- Single-step DOM updates - Update DOM and animate in one operation
- Shared element transitions - Animate specific elements across pages
- Cross-document transitions - Works between separate HTML documents
- Customizable animations - Full control over transition styles
- Fallback support - Works with a polyfill for older browsers
Basic Usage
Simple Page Navigation
<!-- index.html -->
<h1>Home Page</h1>
<a href="about.html">Go to About</a>
<!-- about.html -->
<h1>About Page</h1>
<a href="index.html">Go Home</a>
/* Default transition */
@view-transition {
navigation: auto;
}
/* Custom transition */
::view-transition-old(root) {
animation: fade-out 0.5s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.5s ease-in;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
JavaScript-Controlled Transitions
// Programmatic transition
async function updateContent() {
const transition = document.startViewTransition(() => {
// Update DOM
document.getElementById('content').innerHTML = '<p>New content</p>';
});
// Wait for transition to complete
await transition.finished;
console.log('Transition complete!');
}
With State Updates
// Transition with state change
async function toggleTheme() {
const isDark = document.body.classList.contains('dark');
const transition = document.startViewTransition(() => {
document.body.classList.toggle('dark');
document.body.style.setProperty(
'--bg-color',
isDark ? '#ffffff' : '#1a1a1a'
);
});
await transition.ready;
console.log('Animation started');
}
Shared Element Transitions
The most powerful feature is the ability to animate specific elements that persist across page states.
Naming Elements
<!-- Page 1: Product List -->
<div class="product-list">
<article class="product">
<img src="product1.jpg" style="view-transition-name: product-1">
<h2>Product 1</h2>
</article>
</div>
<!-- Page 2: Product Detail -->
<div class="product-detail">
<img src="product1.jpg" style="view-transition-name: product-1">
<h2>Product 1</h2>
<p>Product description...</p>
</div>
/* The browser automatically animates the shared element */
::view-transition-old(product-1) {
/* Starting state */
}
::view-transition-new(product-1) {
/* Ending state */
}
Complete Example: Gallery
<!-- gallery.html -->
<div class="gallery">
<div class="photo" style="view-transition-name: photo-1">
<img src="photo1-thumb.jpg" onclick="openPhoto('photo1')">
</div>
<div class="photo" style="view-transition-name: photo-2">
<img src="photo2-thumb.jpg" onclick="openPhoto('photo2')">
</div>
</div>
<!-- photo.html (full view) -->
<div class="photo-full">
<img src="photo1-full.jpg" style="view-transition-name: photo-1">
</div>
/* gallery.css */
.photo img {
width: 200px;
height: 150px;
object-fit: cover;
}
.photo-full img {
width: 100%;
max-width: 800px;
height: auto;
}
/* Customize the transition */
::view-transition-old(photo-1) {
animation: 300ms ease-out both;
}
::view-transition-new(photo-1) {
animation: 300ms ease-in both;
}
// gallery.js
function openPhoto(photoId) {
// Set view transition name on full image
const fullImage = document.querySelector('.photo-full img');
fullImage.style.viewTransitionName = photoId;
document.startViewTransition(() => {
window.location.href = `photo.html?id=${photoId}`;
});
}
Customizing Transitions
View Transition Groups
/* Separate transitions for different parts */
::view-transition-old(header) {
animation: slide-out 0.3s ease-out;
}
::view-transition-new(header) {
animation: slide-in 0.3s ease-out;
}
::view-transition-old(main) {
animation: fade-out 0.5s ease-out;
}
::view-transition-new(main) {
animation: fade-in 0.5s ease-in;
}
/* Use pseudo-elements for additional effects */
::view-transition-group(footer) {
z-index: 10;
}
Animation Properties
::view-transition-old(root) {
/* Duration */
animation-duration: 500ms;
/* Timing function */
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
/* Fill mode */
animation-fill-mode: both;
/* Delay */
animation-delay: 100ms;
/* Iteration */
animation-iteration-count: 1;
}
Pre-defined Animations
/* Slide animations */
::view-transition-old(root) {
animation: slide-out-to-right 0.3s ease-out;
}
::view-transition-new(root) {
animation: slide-in-from-left 0.3s ease-out;
}
@keyframes slide-out-to-right {
to { transform: translateX(100%); }
}
@keyframes slide-in-from-left {
from { transform: translateX(-100%); }
}
/* Scale animations */
::view-transition-old(root) {
animation: scale-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: scale-in 0.3s ease-out;
}
@keyframes scale-out {
to { transform: scale(0.8); opacity: 0; }
}
@keyframes scale-in {
from { transform: scale(1.2); opacity: 0; }
}
Handling Multiple Elements
Multiple Shared Elements
<!-- Product card with multiple shared elements -->
<article class="product" onclick="viewProduct()">
<img
src="product.jpg"
style="view-transition-name: product-image"
>
<h2 style="view-transition-name: product-title">Product</h2>
<p style="view-transition-name: product-price">$99</p>
</article>
/* Each element transitions independently */
::view-transition-old(product-image) {
/* Image transition */
}
::view-transition-new(product-image) {
/* Image transition */
}
::view-transition-old(product-title) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(product-title) {
animation: none;
mix-blend-mode: normal;
}
Fallback for Unmatched Elements
/* Elements without matching view-transition-name */
::view-transition-old(*) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(*) {
animation: fade-in 0.3s ease-in;
}
/* Except specifically named elements */
::view-transition-old(product-image),
::view-transition-new(product-image) {
animation: none;
}
JavaScript API Reference
ViewTransition Object
const transition = document.startViewTransition(updateCallback);
// Properties
console.log(transition.ready); // Promise - when animation starts
console.log(transition.finished); // Promise - when animation ends
console.log(transition.updateCallbackDone); // Promise - when DOM updates
// Methods
transition.skipTransition(); // Skip the animation
Waiting for Transition
async function navigate(url) {
const transition = document.startViewTransition(() => {
window.location = url;
});
try {
await transition.ready;
console.log('Animation started');
} catch (e) {
console.log('Transition failed:', e);
}
await transition.finished;
console.log('Navigation complete');
}
Conditional Transitions
function shouldTransition() {
// Only transition on slow devices
const isSlowDevice = navigator.connection?.effectiveType === 'slow-2g';
return !isSlowDevice;
}
async function navigate(url) {
if (!document.startViewTransition || !shouldTransition()) {
window.location = url;
return;
}
document.startViewTransition(() => {
window.location = url;
});
}
Aborting Transition
async function handleClick() {
let transition = document.startViewTransition(() => {
updateContent();
});
// User changed mind
setTimeout(() => {
transition.skipTransition();
}, 100);
}
Single Page Application Usage
React Integration
import { useViewTransition } from './useViewTransition';
function App() {
const { startViewTransition } = useViewTransition();
const navigate = (path) => {
startViewTransition(() => {
router.push(path);
});
};
return (
<nav>
<button onClick={() => navigate('/')}>Home</button>
<button onClick={() => navigate('/about')}>About</button>
</nav>
);
}
// useViewTransition.js
import { useCallback } from 'react';
export function useViewTransition() {
const startViewTransition = useCallback((callback) => {
if (!document.startViewTransition) {
callback();
return;
}
document.startViewTransition(callback);
}, []);
return { startViewTransition };
}
Vue Integration
// router.js
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
// ... routes
]
});
router.beforeEach(async (to, from, next) => {
if (!document.startViewTransition) {
next();
return;
}
const transition = document.startViewTransition(next);
try {
await transition.ready;
} catch (e) {
// Fallback
}
});
export default router;
Vanilla JS SPA Router
class Router {
constructor(routes) {
this.routes = routes;
this.handleClick = this.handleClick.bind(this);
}
init() {
document.addEventListener('click', this.handleClick);
window.addEventListener('popstate', this.handlePopState);
}
async handleClick(e) {
const link = e.target.closest('a');
if (!link || !this.isInternal(link.href)) return;
e.preventDefault();
await this.navigateTo(link.href);
}
async navigateTo(url) {
const route = this.routes[url];
if (!route) return;
const transition = document.startViewTransition(async () => {
const html = await route.load();
document.getElementById('app').innerHTML = html;
history.pushState({}, '', url);
});
await transition.finished;
}
isInternal(href) {
return href.startsWith(window.location.origin);
}
}
Browser Support and Polyfills
// Feature detection
const supportsViewTransitions = 'startViewTransition' in document;
if (!supportsViewTransitions) {
// Use polyfill
import('view-transition-polyfill').then(() => {
console.log('Polyfill loaded');
});
}
<!-- Polyfill script -->
<script async src="https://unpkg.com/view-transition-polyfill/dist/view-transition-polyfill.js"></script>
Fallback CSS
/* Fallback for browsers without View Transitions */
@supports not (view-transition-name: none) {
.fade-transition {
animation: fade 0.3s ease-in-out;
}
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
}
Best Practices
Do: Use Meaningful Names
/* Good: Semantic names */
view-transition-name: header;
view-transition-name: product-image;
view-transition-name: cart-icon;
/* Avoid: Generic names */
view-transition-name: a1;
view-transition-name: element-123;
Don’t: Overlap Transitions
/* Bad: z-index issues */
::view-transition-old(header) { z-index: 100; }
::view-transition-new(product) { z-index: 50; }
/* Good: Clear layering */
::view-transition-group(overlay) { z-index: 9999; }
::view-transition-group(main) { z-index: 1; }
Handle Long Content
/* Prevent overflow during transition */
::view-transition-group(root) {
overflow: hidden;
}
/* Allow scrolling within content */
::view-transition-old(content) {
height: 100%;
overflow: hidden;
}
::view-transition-new(content) {
height: 100%;
overflow: auto;
}
Advanced Patterns
Skeleton Screens
/* Show skeleton during transition */
::view-transition-new(skeleton) {
animation: skeleton-pulse 1s infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
Morphing Elements
/* Create morphing effect */
::view-transition-old(card) {
animation: 300ms ease-out both;
}
::view-transition-new(card) {
animation: 300ms ease-out both;
/* Start from old position */
transform-origin: top left;
}
Gesture-Based Transitions
// Swipe to go back
let startX = 0;
let currentX = 0;
document.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
});
document.addEventListener('touchmove', (e) => {
currentX = e.touches[0].clientX;
const diff = currentX - startX;
if (diff > 0) {
document.documentElement.style.setProperty(
'--swipe-offset',
`${diff}px`
);
}
});
document.addEventListener('touchend', async () => {
if (currentX - startX > 100) {
const transition = document.startViewTransition(() => {
history.back();
});
}
});
External Resources
- MDN View Transitions API
- View Transitions Explainer
- Chrome View Transitions Demo
- Can I Use: View Transitions
Conclusion
The View Transitions API revolutionizes web navigation by enabling smooth, app-like transitions without JavaScript libraries. Key points:
- Use
document.startViewTransition()to initiate transitions - Assign
view-transition-nameto elements you want to persist - Customize with
::view-transition-oldand::view-transition-newpseudo-elements - Works for both multi-page apps and SPAs
This API is supported in Chrome, Edge, and Safari 18+. Firefox has experimental support. Use the polyfill for broader compatibility.
Comments