Skip to main content
โšก Calmops

View Transitions API: Native Page Transitions for the Web

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 */
}
<!-- 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

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-name to elements you want to persist
  • Customize with ::view-transition-old and ::view-transition-new pseudo-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