Lesson 16
View Transitions - Animating Between States
View transitions let you animate between two DOM states - whether that's toggling a UI panel, navigating between pages, or reordering a list. The browser snapshots the "before" state, you make the DOM change, and then it animates from the snapshot to the new state. The result is smooth, app-like transitions with very little code.
The mental model
A view transition works in three phases:
- Snapshot - the browser captures an image of the current state (the "old" view)
- DOM update - you make your changes (swap content, toggle classes, navigate)
- Animate - the browser crossfades from the snapshot to the new state, with optional custom animations
Key concept: The browser does the heavy lifting. You don't manually animate properties between states. You say "I'm about to change something" → change it → the browser figures out how to animate the difference. It's declarative animation of state changes.
Two types of view transitions
- Same-document (SPA) - triggered with
document.startViewTransition()in JavaScript. For single-page apps where you're updating the DOM without a full navigation. - Cross-document (MPA) - triggered automatically on navigation between pages that both opt in with
@view-transition { navigation: auto; }. For multi-page apps and traditional websites.
Same-document transitions (SPA)
The simplest usage - wrap a DOM change in document.startViewTransition():
Click the button - the card crossfades between two states via view transition
.card {
view-transition-name: demo-card;
}
::view-transition-old(demo-card) {
animation: fade-out 0.3s ease;
}
::view-transition-new(demo-card) {
animation: fade-in 0.3s ease;
}
// JavaScript - wrap the DOM change
function toggleCard() {
document.startViewTransition(() => {
container.classList.toggle('state-a', isStateA);
container.classList.toggle('state-b', !isStateA);
card.textContent = isStateA ? 'State A' : 'State B';
});
}
How it works - the full timeline
TIME ──────────────────────────────────────────────────────────────────────►
PHASE 1: CAPTURE OLD STATE
- Browser FREEZES rendering
- Captures bitmap snapshots of elements with view-transition-name
- User sees: frozen page
PHASE 2: UPDATE THE DOM
- Your callback() runs - make all DOM changes here
- Browser renders new DOM state off-screen
- transition.updateCallbackDone resolves
PHASE 3: CREATE PSEUDO-ELEMENTS
- Browser creates ::view-transition tree
- transition.ready resolves
PHASE 4: ANIMATE
- Default: old fades out, new fades in
- Same-named elements: group morphs position/size
- Page NOT interactive during animation
PHASE 5: CLEANUP
- Pseudo-elements removed
- New DOM revealed and interactive
- transition.finished resolves
Key details to note
- The page is NOT interactive during the animation. The user sees images (snapshots), not the real DOM. Keep transitions short (0.2-0.5s).
- The DOM change is instant. Your callback runs synchronously - the new DOM exists immediately. The animation is purely visual over the top.
- If your callback is async (returns a Promise), the browser waits. Useful if you need to fetch data before showing the new state.
- "Same name" elements get a morph, not a crossfade. The
::view-transition-groupsmoothly interpolates width, height, and transform between old and new positions.
The magic: During the animation, the user sees an image (the snapshot), not the actual DOM. This means the DOM change can be instant and jarring - the view transition smooths it visually. Even a complete content swap looks elegant.
view-transition-name - identifying elements across states
This is the core CSS property. It tells the browser: "this element in the old state corresponds to this element in the new state - animate between them."
.card {
view-transition-name: hero-card; /* must be unique on the page */
}
.avatar {
view-transition-name: user-avatar;
}
/* RULE: each name must be unique per page.
Two elements with the same name = error */
Live demo: shared element transition
Click a thumbnail - it expands into a full-width panel (shared element transition)
/* The key: both the thumbnail and the expanded view share
the same view-transition-name. The browser morphs between them. */
.thumb:nth-child(1) { view-transition-name: thumb-1; }
.expanded { view-transition-name: thumb-1; } /* same name! */
// JS
function expandThumb(index) {
document.startViewTransition(() => {
grid.style.display = 'none';
expanded.style.display = 'flex';
expanded.style.viewTransitionName = `thumb-${index + 1}`;
});
}
Shared elements must have the same view-transition-name in both states. The browser matches old→new by name. When it finds a match, instead of crossfading, it morphs: smoothly interpolating position, size, and opacity between the old and new locations.
The pseudo-element tree
::view-transition
├── ::view-transition-group(name)
│ ├── ::view-transition-image-pair(name)
│ │ ├── ::view-transition-old(name) ← the "before" snapshot
│ │ └── ::view-transition-new(name) ← the "after" snapshot
/* Target by name: */
::view-transition-old(demo-card) { } ← "old snapshot OF demo-card"
::view-transition-new(demo-card) { } ← "new snapshot OF demo-card"
/* Special values: */
::view-transition-old(root) { } ← the whole-page transition
::view-transition-group(*) { } ← ALL named transitions
Custom slide animation
Custom animation: the old state slides out left, the new state slides in from the right
.slide-panel {
view-transition-name: slide-demo;
}
::view-transition-old(slide-demo) {
animation: slide-out-left 0.3s ease forwards;
}
::view-transition-new(slide-demo) {
animation: slide-in-right 0.3s ease forwards;
}
@keyframes slide-out-left {
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in-right {
from { transform: translateX(100%); opacity: 0; }
}
Cross-document transitions (MPA)
For traditional multi-page navigations (clicking links that load a new HTML page), you can opt in with a CSS at-rule - no JavaScript needed:
/* Add to BOTH pages (source and destination) */
@view-transition {
navigation: auto;
}
/* Elements with matching view-transition-name on both pages
will morph between positions during navigation */
.page-header {
view-transition-name: header;
}
.hero-image {
view-transition-name: hero;
}
List reordering with view transitions
Click to shuffle - items animate to new positions via view transitions
/* Each item needs a unique view-transition-name */
.item:nth-child(1) { view-transition-name: item-1; }
.item:nth-child(2) { view-transition-name: item-2; }
.item:nth-child(3) { view-transition-name: item-3; }
.item:nth-child(4) { view-transition-name: item-4; }
// JS - shuffle and let view transitions handle the animation
function shuffleList() {
document.startViewTransition(() => {
const items = [...list.children];
items.sort(() => Math.random() - 0.5);
items.forEach(item => list.appendChild(item));
});
}
Tab switching with slide
Click tabs - content slides between panels
.panel.active {
display: block;
view-transition-name: tab-content;
}
::view-transition-old(tab-content) {
animation: slide-out-left 0.25s ease forwards;
}
::view-transition-new(tab-content) {
animation: slide-in-right 0.25s ease forwards;
}
// JavaScript - swap the .active class
function switchTab(id) {
document.startViewTransition(() => {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab' + id).classList.add('active');
});
}
Respecting reduced motion
/* Always include this - disable view transition animations for users
who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Gotchas and limitations
- Unique names required. Two elements with the same
view-transition-nameon a page at the same time = error. - Only works in the top-level document. View transitions don't work inside iframes.
- Snapshot is a flat image. During the animation, you can't interact with the page. Keep transitions short.
- Cross-document requires same origin. You can't animate transitions between different domains.
- Performance. The browser composites the snapshots on the GPU - so it's fast. But capturing large/complex pages can have a brief delay.
Browser support
- Same-document (
startViewTransition): Chrome 111+, Safari 18+, Firefox pending - Cross-document (
@view-transition): Chrome 126+, Safari 18+, Firefox pending - Fully progressive - if unsupported, the DOM change still happens, just without animation
Retrieval check
Question 1
What does view-transition-name do?
Question 2
What does the browser actually show during a view transition animation?
Question 3
How do you trigger a same-document view transition?
Question 4
Two elements on the same page have the same view-transition-name. What happens?
Question 5
How do you enable view transitions for multi-page navigations (no JS)?