CSS View Transitions: Smooth Page and State Animations
View Transitions is a modern browser API that lets you animate the change between two states — whether that's navigating between pages, switching tabs in a UI, or any DOM update. It captures a "before" and "after" screenshot of your page and animates between them with CSS.
1. What View Transitions Do
Without View Transitions: With View Transitions:
Page A → instant blank → Page B Page A → ✨ animated crossfade → Page B
Tab 1 → instant switch → Tab 2 Tab 1 → ✨ slide → Tab 2
View Transitions work in two modes:
- Same-document — JavaScript-triggered state changes (tab switches, filter changes, cart updates)
- Cross-document — Full page navigations (via
@view-transitionCSS rule)
2. Same-Document Transitions (JavaScript API)
The JavaScript document.startViewTransition() function wraps any DOM update:
// Basic usage: wrap any DOM change in startViewTransition
document.querySelector('.js-theme-toggle').addEventListener('click', () => {
// Without View Transitions: instant color change
document.documentElement.setAttribute('data-theme', 'dark');
// With View Transitions: animated crossfade
document.startViewTransition(() => {
document.documentElement.setAttribute('data-theme', 'dark');
});
});
// Tab/panel switching with View Transitions
function switchTab(newTab) {
document.startViewTransition(() => {
// All DOM changes inside here are animated
document.querySelectorAll('.c-tab').forEach(t => t.classList.remove('is-active'));
newTab.classList.add('is-active');
updatePanelContent(newTab.dataset.panel);
});
}
3. The Default Transition: Crossfade
Without any CSS customization, startViewTransition gives you a crossfade between old and new state:
/* The browser creates these pseudo-elements automatically during a transition */
::view-transition /* Root overlay — covers the whole page */
::view-transition-old(root) /* Screenshot of the BEFORE state */
::view-transition-new(root) /* Screenshot of the AFTER state */
/* Default behavior: crossfade (opacity 0→1 on new, 1→0 on old) */
/* Duration: 0.25s — you can customize this */
4. Customizing the Default Transition
/* Override the default crossfade */
::view-transition-old(root) {
animation: fade-out 0.4s ease both;
}
::view-transition-new(root) {
animation: fade-in 0.4s ease both;
}
@keyframes fade-out { to { opacity: 0; } }
@keyframes fade-in { from { opacity: 0; } }
/* Dark mode toggle: use clip-path reveal */
::view-transition-new(root) {
animation: reveal 0.5s ease both;
}
::view-transition-old(root) {
animation: none; /* Keep old state visible underneath */
}
@keyframes reveal {
from { clip-path: circle(0% at 50% 50%); }
to { clip-path: circle(150% at 50% 50%); }
}
5. Named View Transitions — Per-Element Animation
The power of View Transitions: assign view-transition-name to specific elements so they animate independently from the rest of the page:
/* Give specific elements their own transition name */
.c-hero__image {
view-transition-name: hero-image; /* Unique name — must be unique on page */
}
.c-hero__title {
view-transition-name: hero-title;
}
.c-hero__cta {
view-transition-name: hero-cta;
}
/* Now target them individually with their pseudo-elements */
::view-transition-old(hero-image),
::view-transition-new(hero-image) {
animation-duration: 0.6s;
}
::view-transition-new(hero-title) {
animation: slide-up 0.5s ease both;
animation-delay: 0.1s;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
The "Shared Element" Animation
The most impressive View Transitions effect: an element flies from its position on Page A to its position on Page B:
/* On Page A (e.g., product listing) */
.c-product-card__image {
view-transition-name: product-hero; /* Same name as on Page B */
}
/* On Page B (e.g., product detail) */
.c-product-hero {
view-transition-name: product-hero; /* Matches Page A's name */
}
/* Browser automatically:
1. Captures position of .c-product-card__image on Page A
2. Captures position of .c-product-hero on Page B
3. Animates (FLIP) between the two positions
This is the "shared element transition" or "hero animation" */
[!IMPORTANT]
view-transition-namevalues must be unique on the page. If two elements share the same name simultaneously, the browser disables the transition for that pair. Use dynamic names for list items.
6. Dynamic Names for Lists
When you have multiple elements (card grid, list items), generate unique names:
/* CSS: assign unique names to each card */
.c-card:nth-child(1) { view-transition-name: card-1; }
.c-card:nth-child(2) { view-transition-name: card-2; }
/* ... doesn't scale well */
// JavaScript: set view-transition-name dynamically
document.querySelectorAll('.c-card').forEach((card, i) => {
card.style.viewTransitionName = `card-${i}`;
});
// On navigate: read the clicked card's ID
function navigateToCard(cardEl, url) {
const id = cardEl.dataset.id;
cardEl.style.viewTransitionName = `card-${id}`;
document.startViewTransition(async () => {
// On the new page, the matching element also needs the same name
await navigate(url); // Replace DOM with new page content
document.querySelector('.c-product-hero').style.viewTransitionName = `card-${id}`;
});
}
7. Cross-Document View Transitions (No JavaScript)
For full page navigations — no JavaScript needed at all:
/* Add this to BOTH pages involved in the transition */
@view-transition {
navigation: auto; /* Enable cross-document transitions for this page */
}
/* That's it — browser automatically crossfades between pages */
/* Customize with the same pseudo-elements */
@view-transition { navigation: auto; }
/* Shared element across pages: same view-transition-name on both */
/* Page A */
.c-card__image { view-transition-name: product-image; }
/* Page B */
.c-product-header__image { view-transition-name: product-image; }
/* Browser flies the image from listing to detail page */
8. Respecting prefers-reduced-motion
Always disable or simplify View Transitions for users with motion sensitivity:
/* Disable all View Transitions for reduced-motion users */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
// JavaScript: check before using View Transitions
function transitionIfAllowed(updateFn) {
if (!document.startViewTransition ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateFn(); /* Just update instantly — no animation */
return;
}
document.startViewTransition(updateFn);
}
9. WordPress Integration
// functions.php — add View Transitions to all pages
function add_view_transitions() {
echo '<style>@view-transition { navigation: auto; }</style>';
}
add_action('wp_head', 'add_view_transitions');
/* child theme style.css — customize the default transition */
@view-transition {
navigation: auto;
}
/* Fade the page content, keep header static */
.site-header { view-transition-name: site-header; }
.site-content { view-transition-name: site-content; }
::view-transition-old(site-content) {
animation: slide-out-left 0.3s ease both;
}
::view-transition-new(site-content) {
animation: slide-in-right 0.3s ease both;
animation-delay: 0.1s;
}
::view-transition-old(site-header),
::view-transition-new(site-header) {
animation: none; /* Header stays fixed — no animation */
}
@keyframes slide-out-left {
to { opacity: 0; transform: translateX(-40px); }
}
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(40px); }
}
10. Browser Support
| Browser | Support | Notes |
|---|---|---|
| Chrome | ✅ 111+ | Full same-doc + cross-doc |
| Edge | ✅ 111+ | Full same-doc + cross-doc |
| Safari | ✅ 18.2+ | Full support |
| Firefox | ⚠ Partial | Same-doc only (cross-doc behind flag) |
| Global | ~85% | Growing rapidly |
/* Feature detection: @supports for View Transitions */
@supports (view-transition-name: none) {
/* View transitions CSS here */
.c-hero { view-transition-name: hero; }
}
/* JavaScript feature detection */
if (document.startViewTransition) {
document.startViewTransition(() => updateDOM());
} else {
updateDOM(); /* Fallback: instant update */
}
11. AI Prompt for View Transitions
Add View Transitions to these UI interactions:
1. [Dark/light mode toggle]
2. [Tab switching in .c-tabs component]
3. [Card → detail navigation]
Requirements:
- Use document.startViewTransition() wrapping DOM updates
- Assign view-transition-name to: [LIST ELEMENTS]
- Custom animation for [ELEMENT]: [DESCRIBE: fade/slide/scale/clip-path]
- Default transition: [crossfade / directional slide]
- Duration: [0.3s for snappy / 0.5s for dramatic]
- MUST include prefers-reduced-motion override (animation:none)
- MUST include feature detection fallback
Output: CSS (::view-transition pseudo-elements + keyframes) + JS wrapper