Back to Blog
CSSView TransitionsAnimationSPA

CSS View Transitions API

Create smooth page transitions with the View Transitions API. From basics to cross-document animations.

B
Bootspring Team
Engineering
March 1, 2021
6 min read

The View Transitions API creates smooth animated transitions between DOM states. Here's how to use it.

Basic View Transition#

1// Simple transition 2document.startViewTransition(() => { 3 // Update the DOM 4 updateContent(); 5}); 6 7// With promises 8async function navigate(url) { 9 const response = await fetch(url); 10 const html = await response.text(); 11 12 document.startViewTransition(() => { 13 document.body.innerHTML = html; 14 }); 15} 16 17// Check support 18if (document.startViewTransition) { 19 document.startViewTransition(() => updateDOM()); 20} else { 21 updateDOM(); 22}

Default Animation#

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

Named Transitions#

1/* Assign transition names to elements */ 2.header { 3 view-transition-name: header; 4} 5 6.main-content { 7 view-transition-name: main; 8} 9 10.sidebar { 11 view-transition-name: sidebar; 12} 13 14/* Style each transition independently */ 15::view-transition-old(header), 16::view-transition-new(header) { 17 animation-duration: 0.2s; 18} 19 20::view-transition-old(main), 21::view-transition-new(main) { 22 animation-duration: 0.4s; 23} 24 25::view-transition-old(sidebar), 26::view-transition-new(sidebar) { 27 animation-duration: 0.3s; 28 animation-timing-function: ease-out; 29}

Slide Transitions#

1/* Slide in from right */ 2@keyframes slide-from-right { 3 from { transform: translateX(100%); } 4 to { transform: translateX(0); } 5} 6 7@keyframes slide-to-left { 8 from { transform: translateX(0); } 9 to { transform: translateX(-100%); } 10} 11 12::view-transition-old(main) { 13 animation: slide-to-left 0.3s ease-out; 14} 15 16::view-transition-new(main) { 17 animation: slide-from-right 0.3s ease-out; 18} 19 20/* Slide in from bottom */ 21@keyframes slide-up { 22 from { transform: translateY(100%); } 23 to { transform: translateY(0); } 24} 25 26@keyframes slide-down { 27 from { transform: translateY(0); } 28 to { transform: translateY(100%); } 29} 30 31.modal { 32 view-transition-name: modal; 33} 34 35::view-transition-old(modal) { 36 animation: slide-down 0.3s ease-out; 37} 38 39::view-transition-new(modal) { 40 animation: slide-up 0.3s ease-out; 41}

Shared Element Transitions#

1/* Same element transitions between states */ 2.card-image { 3 view-transition-name: card-image; 4} 5 6/* On detail page */ 7.hero-image { 8 view-transition-name: card-image; 9} 10 11/* The element morphs between positions */ 12::view-transition-old(card-image), 13::view-transition-new(card-image) { 14 animation-duration: 0.5s; 15 animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 16}
1// Dynamic view transition names 2function setTransitionName(element, name) { 3 element.style.viewTransitionName = name; 4} 5 6async function navigateToDetail(card) { 7 // Set unique name on clicked card 8 setTransitionName(card.querySelector('img'), 'hero'); 9 10 await document.startViewTransition(async () => { 11 await loadDetailPage(); 12 }).finished; 13 14 // Clear name after transition 15 setTransitionName(card.querySelector('img'), ''); 16}

Direction-Based Animations#

1// Track navigation direction 2let navigationType = 'forward'; 3 4function navigate(url, direction = 'forward') { 5 navigationType = direction; 6 document.startViewTransition(() => loadPage(url)); 7}
1/* Apply different animations based on direction */ 2.page { 3 view-transition-name: page; 4} 5 6/* Forward navigation */ 7html:has([data-direction="forward"]) { 8 &::view-transition-old(page) { 9 animation: slide-to-left 0.3s ease-out; 10 } 11 &::view-transition-new(page) { 12 animation: slide-from-right 0.3s ease-out; 13 } 14} 15 16/* Back navigation */ 17html:has([data-direction="back"]) { 18 &::view-transition-old(page) { 19 animation: slide-to-right 0.3s ease-out; 20 } 21 &::view-transition-new(page) { 22 animation: slide-from-left 0.3s ease-out; 23 } 24}

React Integration#

1// useViewTransition hook 2function useViewTransition() { 3 const startTransition = useCallback( 4 (callback: () => void | Promise<void>) => { 5 if (!document.startViewTransition) { 6 callback(); 7 return; 8 } 9 10 document.startViewTransition(async () => { 11 await callback(); 12 }); 13 }, 14 [] 15 ); 16 17 return { startTransition }; 18} 19 20// Usage 21function Gallery() { 22 const [selectedId, setSelectedId] = useState<string | null>(null); 23 const { startTransition } = useViewTransition(); 24 25 function selectImage(id: string) { 26 startTransition(() => { 27 setSelectedId(id); 28 }); 29 } 30 31 return ( 32 <div> 33 {selectedId ? ( 34 <DetailView 35 id={selectedId} 36 onClose={() => startTransition(() => setSelectedId(null))} 37 /> 38 ) : ( 39 <GridView onSelect={selectImage} /> 40 )} 41 </div> 42 ); 43}

Next.js App Router#

1// app/layout.tsx 2'use client'; 3 4import { usePathname } from 'next/navigation'; 5import { useEffect, useRef } from 'react'; 6 7export default function Layout({ children }) { 8 const pathname = usePathname(); 9 const previousPathname = useRef(pathname); 10 11 useEffect(() => { 12 if (previousPathname.current !== pathname) { 13 if (document.startViewTransition) { 14 document.startViewTransition(); 15 } 16 previousPathname.current = pathname; 17 } 18 }, [pathname]); 19 20 return <>{children}</>; 21}

Cross-Document Transitions#

<!-- Enable cross-document transitions --> <head> <meta name="view-transition" content="same-origin"> </head>
1/* Works across page navigations */ 2@view-transition { 3 navigation: auto; 4} 5 6/* Named elements persist across pages */ 7.persistent-header { 8 view-transition-name: header; 9} 10 11/* Style the transitions */ 12::view-transition-group(header) { 13 animation-duration: 0.3s; 14}

List Animations#

/* FLIP-style list animations */ .list-item { view-transition-name: var(--item-id); }
1// Generate unique transition names 2function updateList(items) { 3 items.forEach((item, index) => { 4 const element = document.querySelector(`[data-id="${item.id}"]`); 5 element.style.viewTransitionName = `item-${item.id}`; 6 }); 7 8 document.startViewTransition(() => { 9 renderList(items); 10 }); 11}

Reduced Motion#

1/* Respect user preference */ 2@media (prefers-reduced-motion: reduce) { 3 ::view-transition-group(*), 4 ::view-transition-old(*), 5 ::view-transition-new(*) { 6 animation: none !important; 7 } 8} 9 10/* Or provide simpler animation */ 11@media (prefers-reduced-motion: reduce) { 12 ::view-transition-old(root), 13 ::view-transition-new(root) { 14 animation-duration: 0.01s; 15 } 16}

Debugging#

1/* Slow down for debugging */ 2::view-transition-group(*), 3::view-transition-old(*), 4::view-transition-new(*) { 5 animation-duration: 3s !important; 6} 7 8/* Visualize layers */ 9::view-transition-old(*) { 10 outline: 2px solid red; 11} 12 13::view-transition-new(*) { 14 outline: 2px solid blue; 15}
1// Log transition events 2const transition = document.startViewTransition(() => { 3 updateDOM(); 4}); 5 6transition.ready.then(() => { 7 console.log('Transition ready'); 8}); 9 10transition.finished.then(() => { 11 console.log('Transition finished'); 12}); 13 14transition.updateCallbackDone.then(() => { 15 console.log('DOM update complete'); 16});

Performance Tips#

1/* Contain painted areas */ 2.transition-element { 3 view-transition-name: element; 4 contain: paint; 5} 6 7/* Avoid animating layout properties */ 8::view-transition-old(*), 9::view-transition-new(*) { 10 /* Use transform and opacity */ 11 animation: fade-scale 0.3s ease-out; 12} 13 14@keyframes fade-scale { 15 from { 16 opacity: 0; 17 transform: scale(0.95); 18 } 19 to { 20 opacity: 1; 21 transform: scale(1); 22 } 23}

Fallback Pattern#

1async function transitionTo(update) { 2 if (!document.startViewTransition) { 3 // Fallback for unsupported browsers 4 await update(); 5 return; 6 } 7 8 const transition = document.startViewTransition(update); 9 10 try { 11 await transition.finished; 12 } catch (e) { 13 // Handle aborted transitions 14 console.warn('Transition aborted:', e); 15 } 16} 17 18// Usage 19transitionTo(async () => { 20 const data = await fetchData(); 21 renderContent(data); 22});

Best Practices#

Implementation: ✓ Check for API support ✓ Use meaningful transition names ✓ Keep animations short (200-500ms) ✓ Respect reduced motion preference Performance: ✓ Use transform and opacity ✓ Avoid too many named transitions ✓ Use contain: paint ✓ Test on real devices UX: ✓ Match animation to action ✓ Use shared elements for continuity ✓ Provide visual feedback ✓ Keep transitions subtle

Conclusion#

The View Transitions API enables smooth, native-feeling transitions between DOM states. Use named transitions for shared elements, customize animations with CSS, and always provide fallbacks for unsupported browsers. Combined with modern frameworks, it creates polished user experiences.

Share this article

Help spread the word about Bootspring