Back to Blog
CSSView TransitionsAnimationUX

CSS View Transitions Guide

Master CSS View Transitions API for smooth page and state transitions.

B
Bootspring Team
Engineering
December 30, 2018
6 min read

The View Transitions API enables smooth animated transitions between page states or views. Here's how to use it.

Basic View Transition#

1// Trigger a view transition 2async function updateView() { 3 // Check for support 4 if (!document.startViewTransition) { 5 updateDOM(); 6 return; 7 } 8 9 // Start transition 10 const transition = document.startViewTransition(() => { 11 updateDOM(); 12 }); 13 14 // Wait for transition to complete 15 await transition.finished; 16} 17 18function updateDOM() { 19 document.querySelector('.content').innerHTML = newContent; 20}

Default Transition CSS#

1/* Default crossfade animation */ 2::view-transition-old(root) { 3 animation: fade-out 0.3s ease-out; 4} 5 6::view-transition-new(root) { 7 animation: fade-in 0.3s ease-in; 8} 9 10@keyframes fade-out { 11 from { opacity: 1; } 12 to { opacity: 0; } 13} 14 15@keyframes fade-in { 16 from { opacity: 0; } 17 to { opacity: 1; } 18}

Named Transitions#

1/* Assign unique transition names */ 2.card { 3 view-transition-name: card; 4} 5 6.header { 7 view-transition-name: header; 8} 9 10.main-content { 11 view-transition-name: main; 12} 13 14/* Style specific element transitions */ 15::view-transition-old(card) { 16 animation: slide-out 0.3s ease-out; 17} 18 19::view-transition-new(card) { 20 animation: slide-in 0.3s ease-in; 21} 22 23@keyframes slide-out { 24 to { transform: translateX(-100%); opacity: 0; } 25} 26 27@keyframes slide-in { 28 from { transform: translateX(100%); opacity: 0; } 29}
1// SPA navigation with transitions 2async function navigate(url) { 3 const response = await fetch(url); 4 const html = await response.text(); 5 6 if (!document.startViewTransition) { 7 document.body.innerHTML = html; 8 return; 9 } 10 11 const transition = document.startViewTransition(() => { 12 document.body.innerHTML = html; 13 }); 14 15 await transition.ready; 16 // Transition has started 17 18 await transition.finished; 19 // Transition complete 20}

MPA View Transitions#

1/* Enable cross-document transitions */ 2@view-transition { 3 navigation: auto; 4} 5 6/* Shared elements across pages */ 7.product-image { 8 view-transition-name: product-hero; 9} 10 11.page-title { 12 view-transition-name: title; 13}

Directional Transitions#

1// Track navigation direction 2let navigatingBack = false; 3 4window.addEventListener('popstate', () => { 5 navigatingBack = true; 6}); 7 8async function navigate(url, pushState = true) { 9 const transition = document.startViewTransition(async () => { 10 const response = await fetch(url); 11 document.body.innerHTML = await response.text(); 12 }); 13 14 // Set direction class for CSS 15 document.documentElement.classList.toggle('back', navigatingBack); 16 navigatingBack = false; 17 18 if (pushState) { 19 history.pushState({}, '', url); 20 } 21 22 await transition.finished; 23 document.documentElement.classList.remove('back'); 24}
1/* Forward navigation */ 2::view-transition-old(root) { 3 animation: slide-to-left 0.3s ease-out; 4} 5 6::view-transition-new(root) { 7 animation: slide-from-right 0.3s ease-in; 8} 9 10/* Backward navigation */ 11.back::view-transition-old(root) { 12 animation: slide-to-right 0.3s ease-out; 13} 14 15.back::view-transition-new(root) { 16 animation: slide-from-left 0.3s ease-in; 17} 18 19@keyframes slide-to-left { 20 to { transform: translateX(-30%); opacity: 0; } 21} 22 23@keyframes slide-from-right { 24 from { transform: translateX(30%); opacity: 0; } 25} 26 27@keyframes slide-to-right { 28 to { transform: translateX(30%); opacity: 0; } 29} 30 31@keyframes slide-from-left { 32 from { transform: translateX(-30%); opacity: 0; } 33}

Shared Element Transitions#

1/* Product list page */ 2.product-card-image { 3 view-transition-name: product-image; 4} 5 6/* Product detail page */ 7.product-hero-image { 8 view-transition-name: product-image; 9} 10 11/* Shared element morphs between states */ 12::view-transition-group(product-image) { 13 animation-duration: 0.4s; 14 animation-timing-function: ease-in-out; 15}

Card Expansion#

1async function expandCard(card) { 2 // Give the card a unique transition name 3 card.style.viewTransitionName = 'expanding-card'; 4 5 const transition = document.startViewTransition(() => { 6 card.classList.add('expanded'); 7 }); 8 9 await transition.finished; 10} 11 12async function collapseCard(card) { 13 const transition = document.startViewTransition(() => { 14 card.classList.remove('expanded'); 15 }); 16 17 await transition.finished; 18 card.style.viewTransitionName = ''; 19}
1.card { 2 width: 300px; 3 height: 200px; 4} 5 6.card.expanded { 7 position: fixed; 8 top: 50%; 9 left: 50%; 10 transform: translate(-50%, -50%); 11 width: 80vw; 12 height: 80vh; 13} 14 15::view-transition-group(expanding-card) { 16 animation-duration: 0.4s; 17 animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 18}

List Reordering#

1async function sortList(criteria) { 2 // Assign unique names to each item 3 items.forEach((item, i) => { 4 item.style.viewTransitionName = `item-${item.dataset.id}`; 5 }); 6 7 const transition = document.startViewTransition(() => { 8 const sorted = [...items].sort((a, b) => 9 a.dataset[criteria].localeCompare(b.dataset[criteria]) 10 ); 11 12 container.innerHTML = ''; 13 sorted.forEach(item => container.appendChild(item)); 14 }); 15 16 await transition.finished; 17 18 // Clean up 19 items.forEach(item => { 20 item.style.viewTransitionName = ''; 21 }); 22}
1/* Smooth item repositioning */ 2::view-transition-group(*) { 3 animation-duration: 0.3s; 4} 5 6/* Add slight scale effect */ 7::view-transition-old(*):only-child { 8 animation: scale-down 0.3s ease-out; 9} 10 11::view-transition-new(*):only-child { 12 animation: scale-up 0.3s ease-in; 13}

Tab Switching#

1async function switchTab(newTab) { 2 const tabContent = document.querySelector('.tab-content'); 3 tabContent.style.viewTransitionName = 'tab-content'; 4 5 const transition = document.startViewTransition(() => { 6 // Update active tab 7 document.querySelectorAll('.tab').forEach(t => 8 t.classList.toggle('active', t === newTab) 9 ); 10 11 // Update content 12 tabContent.innerHTML = getTabContent(newTab.dataset.tab); 13 }); 14 15 await transition.finished; 16}
1/* Fade and slide tab content */ 2::view-transition-old(tab-content) { 3 animation: fade-slide-out 0.2s ease-out; 4} 5 6::view-transition-new(tab-content) { 7 animation: fade-slide-in 0.2s ease-in 0.1s both; 8} 9 10@keyframes fade-slide-out { 11 to { 12 opacity: 0; 13 transform: translateY(-10px); 14 } 15} 16 17@keyframes fade-slide-in { 18 from { 19 opacity: 0; 20 transform: translateY(10px); 21 } 22}

Dark Mode Toggle#

1async function toggleDarkMode() { 2 const transition = document.startViewTransition(() => { 3 document.documentElement.classList.toggle('dark'); 4 }); 5 6 await transition.ready; 7 // Custom animation started 8}
1/* Circular reveal effect */ 2::view-transition-old(root), 3::view-transition-new(root) { 4 animation: none; 5 mix-blend-mode: normal; 6} 7 8.dark::view-transition-old(root) { 9 z-index: 1; 10} 11 12.dark::view-transition-new(root) { 13 z-index: 999; 14 animation: reveal 0.5s ease-out; 15} 16 17@keyframes reveal { 18 from { 19 clip-path: circle(0% at top right); 20 } 21 to { 22 clip-path: circle(150% at top right); 23 } 24}

Transition Types#

1// Different transitions for different actions 2async function performAction(type, callback) { 3 document.documentElement.dataset.transitionType = type; 4 5 const transition = document.startViewTransition(callback); 6 await transition.finished; 7 8 delete document.documentElement.dataset.transitionType; 9} 10 11// Usage 12performAction('slide', () => showNextSlide()); 13performAction('fade', () => updateContent()); 14performAction('zoom', () => openModal());
1/* Type-specific animations */ 2[data-transition-type="slide"]::view-transition-old(root) { 3 animation: slide-out-left 0.3s; 4} 5 6[data-transition-type="fade"]::view-transition-old(root) { 7 animation: fade-out 0.3s; 8} 9 10[data-transition-type="zoom"]::view-transition-old(root) { 11 animation: zoom-out 0.3s; 12}

Reduce Motion#

1/* Respect user preferences */ 2@media (prefers-reduced-motion: reduce) { 3 ::view-transition-group(*), 4 ::view-transition-old(*), 5 ::view-transition-new(*) { 6 animation: none !important; 7 } 8}

Best Practices#

Implementation: ✓ Check for API support ✓ Use meaningful transition names ✓ Clean up transition names after ✓ Respect prefers-reduced-motion Performance: ✓ Keep animations short (200-400ms) ✓ Use transform and opacity ✓ Avoid animating layout properties ✓ Limit number of transitioning elements UX: ✓ Match animation to action ✓ Use directional cues ✓ Keep transitions subtle ✓ Provide visual continuity Avoid: ✗ Overusing transitions ✗ Long animation durations ✗ Complex nested transitions ✗ Ignoring accessibility

Conclusion#

The View Transitions API enables smooth, native transitions between page states. Use named transitions for shared elements, customize animations with CSS, and implement directional transitions for navigation. Always respect user preferences for reduced motion and keep animations short and purposeful for the best user experience.

Share this article

Help spread the word about Bootspring