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}Page Navigation#
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.