← Back to Index

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:

  1. Snapshot - the browser captures an image of the current state (the "old" view)
  2. DOM update - you make your changes (swap content, toggle classes, navigate)
  3. 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 transitions (SPA)

The simplest usage - wrap a DOM change in document.startViewTransition():

Click the button - the card crossfades between two states via view transition

State A - blue background
.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 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)

Click to close
/* 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.

Ad

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

Panel A content
.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

Item 1
Item 2
Item 3
Item 4
/* 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

Tab A content - first panel
.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

Browser support

Retrieval check

Question 1

What does view-transition-name do?

Triggers a view transition automatically on hover
Identifies an element so the browser can match it between old and new states
Names a @keyframes animation for the transition
Creates a snapshot of the element for caching

Question 2

What does the browser actually show during a view transition animation?

The live DOM being modified in real time
Static image snapshots (pseudo-elements) of the old and new states
A canvas element with the transition painted on it
The new DOM with opacity transitioning from 0 to 1

Question 3

How do you trigger a same-document view transition?

Add transition: view 0.3s to the element
Use the CSS @view-transition at-rule
Call document.startViewTransition(() => { /* DOM changes */ })
Set view-transition: auto on the container

Question 4

Two elements on the same page have the same view-transition-name. What happens?

They both animate together as one group
The second one overrides the first
The view transition fails - names must be unique per page
They crossfade between each other

Question 5

How do you enable view transitions for multi-page navigations (no JS)?

Add view-transition: auto to the body element
Add @view-transition { navigation: auto; } to both pages' CSS
Include a <meta name="view-transition"> tag
It works automatically in all modern browsers