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.