CSS View Transitions: The Complete Guide for 2026

Published on — 18 min read

The View Transitions API brings native, smooth page transitions to the web. No more janky DOM swaps, no more complex FLIP animation libraries, no more layout thrashing hacks. With a single API call, the browser captures the old state, applies your changes, and animates between snapshots using CSS you control. This guide covers everything from basic same-document transitions to cross-document MPA support, custom animations, transition types, and production-ready patterns.

1. What Are View Transitions?

View Transitions are a browser API that animates between two visual states of a document. Instead of elements jumping instantly from one layout to another, the browser creates a smooth crossfade or custom animation between the old and new states.

The core idea is simple: the browser takes a screenshot of the current page (or specific elements), you update the DOM, the browser takes a screenshot of the new state, and then it animates between the two using CSS animations on generated pseudo-elements.

There are two flavors:

Both use the same CSS pseudo-elements and animation model under the hood, so learning one transfers directly to the other.

2. Browser Support

Same-document view transitions are widely supported as of 2026:

Cross-document view transitions have slightly narrower support:

Always use feature detection rather than browser sniffing:

// Same-document transition feature check
if (document.startViewTransition) {
  document.startViewTransition(() => updateDOM());
} else {
  updateDOM(); // Fallback: instant update
}

3. Same-Document Transitions

The document.startViewTransition() method is the entry point for same-document transitions. You pass it a callback that performs your DOM update.

function switchView(newContent) {
  const transition = document.startViewTransition(() => {
    document.getElementById('main').innerHTML = newContent;
  });

  // The transition object gives you lifecycle promises
  transition.ready.then(() => {
    console.log('Pseudo-elements created, animation starting');
  });

  transition.finished.then(() => {
    console.log('Animation complete, pseudo-elements removed');
  });
}

The transition lifecycle works in four steps:

  1. The browser captures the old state as a screenshot
  2. Your callback runs, updating the DOM
  3. The browser captures the new state
  4. Pseudo-elements animate from old to new

The callback can be synchronous or return a Promise. If it returns a Promise, the browser waits for it to resolve before capturing the new state:

document.startViewTransition(async () => {
  const response = await fetch('/api/page-data');
  const data = await response.json();
  renderPage(data);
});

4. The Pseudo-Element Tree

During a view transition, the browser generates a tree of pseudo-elements overlaid on the page. Understanding this tree is key to customizing animations.

::view-transition
  ::view-transition-group(root)
    ::view-transition-image-pair(root)
      ::view-transition-old(root)
      ::view-transition-new(root)
  ::view-transition-group(hero)
    ::view-transition-image-pair(hero)
      ::view-transition-old(hero)
      ::view-transition-new(hero)

Each named element gets its own group. The key pseudo-elements are:

The default animation is a simple crossfade lasting 250ms. The ::view-transition-group also interpolates width, height, and transform between the old and new positions, creating a morph effect for named elements.

5. Customizing Animations

Since the pseudo-elements use standard CSS animations, you customize them the same way you would any animation.

Change duration and easing

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 400ms;
  animation-timing-function: ease-in-out;
}

Slide transition

@keyframes slide-out-left {
  to { transform: translateX(-100%); }
}
@keyframes slide-in-right {
  from { transform: translateX(100%); }
}

::view-transition-old(root) {
  animation: slide-out-left 300ms ease-in both;
}
::view-transition-new(root) {
  animation: slide-in-right 300ms ease-out both;
}

Scale and fade transition

@keyframes scale-down-fade {
  to {
    transform: scale(0.94);
    opacity: 0;
  }
}
@keyframes scale-up-fade {
  from {
    transform: scale(1.06);
    opacity: 0;
  }
}

::view-transition-old(root) {
  animation: scale-down-fade 250ms ease-in both;
}
::view-transition-new(root) {
  animation: scale-up-fade 250ms ease-out both;
}

Targeting all transitions with the wildcard

/* Apply to every named transition at once */
::view-transition-group(*) {
  animation-duration: 350ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

For more on CSS animation fundamentals, see our CSS Animations Complete Guide.

6. view-transition-name

The view-transition-name CSS property tells the browser to track a specific element independently during the transition. Instead of being part of the default root crossfade, the element gets its own ::view-transition-group and morphs from its old position to its new position.

.hero-image {
  view-transition-name: hero;
}

.page-title {
  view-transition-name: title;
}

Rules for view-transition-name:

Dynamic naming with JavaScript

For list items where you want each card to morph independently, assign names dynamically:

// Before starting the transition, assign unique names
cards.forEach((card, i) => {
  card.style.viewTransitionName = `card-${i}`;
});

document.startViewTransition(() => {
  reorderCards();
});

// Clean up after transition
transition.finished.then(() => {
  cards.forEach(card => {
    card.style.viewTransitionName = '';
  });
});

7. Cross-Document Transitions (MPA)

Cross-document view transitions work across full page navigations without JavaScript. You opt in via the @view-transition at-rule in CSS on both the source and destination pages.

/* Add to both pages */
@view-transition {
  navigation: auto;
}

That single rule tells the browser: when navigating between same-origin pages that both have this rule, create a view transition automatically. The browser captures the old page before unloading, loads the new page, and animates between them.

Matching elements across pages

Use the same view-transition-name on corresponding elements in both pages to create morphing animations:

/* Page 1: article listing */
.article-thumbnail {
  view-transition-name: article-hero;
}

/* Page 2: article detail */
.article-hero-image {
  view-transition-name: article-hero;
}

The browser matches elements by name and morphs size, position, and visual appearance between pages. The element on the old page shrinks/moves/fades into the element on the new page.

Controlling direction

You can use JavaScript events to customize cross-document transitions:

// On the outgoing page
window.addEventListener('pageswap', (event) => {
  if (event.viewTransition) {
    // Customize based on where we are going
    const url = new URL(event.activation?.entry?.url);
    if (url.pathname.startsWith('/blog/')) {
      document.documentElement.dataset.transition = 'slide-left';
    }
  }
});

// On the incoming page
window.addEventListener('pagereveal', (event) => {
  if (event.viewTransition) {
    // Clean up data attributes
    delete document.documentElement.dataset.transition;
  }
});

8. Transition Types

Transition types let you apply different animation styles based on the kind of navigation happening. You set types when starting a transition:

// Same-document: set types in the options object
document.startViewTransition({
  update: () => updateDOM(),
  types: ['slide', 'forwards']
});

Then target those types in CSS using the :active-view-transition-type() pseudo-class:

/* Slide forward */
:root:active-view-transition-type(forwards) {
  &::view-transition-old(root) {
    animation: slide-out-left 300ms ease-in;
  }
  &::view-transition-new(root) {
    animation: slide-in-right 300ms ease-out;
  }
}

/* Slide backward */
:root:active-view-transition-type(backwards) {
  &::view-transition-old(root) {
    animation: slide-out-right 300ms ease-in;
  }
  &::view-transition-new(root) {
    animation: slide-in-left 300ms ease-out;
  }
}

For cross-document transitions, types are set via the @view-transition rule:

@view-transition {
  navigation: auto;
  types: slide, fade;
}

9. view-transition-class

The view-transition-class property lets you style groups of named transitions without writing separate rules for each name. It solves the problem of having many uniquely named elements that should share the same animation.

/* Each card has a unique name but shares a class */
.card:nth-child(1) { view-transition-name: card-1; }
.card:nth-child(2) { view-transition-name: card-2; }
.card:nth-child(3) { view-transition-name: card-3; }

/* All cards share the same transition class */
.card {
  view-transition-class: card;
}

/* Style all card transitions at once using the class */
::view-transition-group(*.card) {
  animation-duration: 400ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

The *.card selector targets any ::view-transition-group whose element has the view-transition-class: card property set. This is far more maintainable than writing ::view-transition-group(card-1), ::view-transition-group(card-2) and so on.

10. Real-World Examples

Page navigation (SPA router)

async function navigate(path) {
  const response = await fetch(path);
  const html = await response.text();

  if (!document.startViewTransition) {
    updatePage(html);
    return;
  }

  const transition = document.startViewTransition({
    update: () => updatePage(html),
    types: getTransitionType(path)
  });
}

function getTransitionType(path) {
  const depth = path.split('/').length;
  const currentDepth = location.pathname.split('/').length;
  return depth > currentDepth ? ['forwards'] : ['backwards'];
}

function updatePage(html) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');
  document.title = doc.title;
  document.querySelector('main').replaceWith(
    doc.querySelector('main')
  );
}

Theme switching

function toggleTheme() {
  const transition = document.startViewTransition(() => {
    document.documentElement.classList.toggle('dark');
  });

  // Circular reveal animation from the toggle button
  transition.ready.then(() => {
    const btn = document.querySelector('.theme-btn');
    const { top, left, width, height } = btn.getBoundingClientRect();
    const x = left + width / 2;
    const y = top + height / 2;
    const endRadius = Math.hypot(
      Math.max(x, window.innerWidth - x),
      Math.max(y, window.innerHeight - y)
    );

    document.documentElement.animate(
      { clipPath: [
        `circle(0px at ${x}px ${y}px)`,
        `circle(${endRadius}px at ${x}px ${y}px)`
      ]},
      {
        duration: 500,
        easing: 'ease-in-out',
        pseudoElement: '::view-transition-new(root)'
      }
    );
  });
}
/* Disable default crossfade for theme switch */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

List reordering

function sortList(compareFn) {
  const list = document.querySelector('.sortable-list');
  const items = [...list.children];

  // Assign unique names
  items.forEach((item, i) => {
    item.style.viewTransitionName = `item-${item.dataset.id}`;
  });

  document.startViewTransition(() => {
    items.sort(compareFn);
    items.forEach(item => list.appendChild(item));
  });
}
/* Smooth position animation for reordered items */
::view-transition-group(*) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

Image gallery

/* Thumbnail in the grid */
.gallery-thumb {
  view-transition-name: var(--vt-name);
  cursor: pointer;
}

/* Full-size in the lightbox */
.lightbox-image {
  view-transition-name: var(--vt-name);
}

/* Smooth morph from thumbnail to full size */
::view-transition-group(gallery-img) {
  animation-duration: 350ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

::view-transition-old(gallery-img) {
  animation: none;
}
::view-transition-new(gallery-img) {
  animation: none;
}
function openLightbox(thumb) {
  const id = thumb.dataset.id;
  // Set matching transition name on thumb
  thumb.style.viewTransitionName = 'gallery-img';

  document.startViewTransition(() => {
    thumb.style.viewTransitionName = '';
    showLightbox(id);
    const lightboxImg = document.querySelector('.lightbox-image');
    lightboxImg.style.viewTransitionName = 'gallery-img';
  });
}

11. Comparing to the FLIP Technique

The FLIP (First, Last, Invert, Play) technique has been the go-to approach for smooth layout animations since Paul Lewis introduced it in 2015. View Transitions essentially automate the FLIP pattern at the browser level.

Aspect FLIP View Transitions
Setup Manual getBoundingClientRect calls One API call
Animation control Web Animations API or CSS CSS pseudo-elements
Cross-page Not possible natively Built-in MPA support
Layout thrashing Risk of forced reflow Browser-optimized
Snapshot Must manage yourself Automatic raster capture
Fallback Works everywhere Needs feature detection

For new projects in 2026, View Transitions should be your default choice. Reserve FLIP for cases where you need fine-grained per-frame control or must support browsers without View Transition support.

12. Performance Tips

// Good pattern: clean up after transition
const transition = document.startViewTransition(() => {
  updateDOM();
});

transition.finished.then(() => {
  // Remove dynamic view-transition-names
  document.querySelectorAll('[style*="view-transition-name"]')
    .forEach(el => el.style.viewTransitionName = '');
});

13. Accessibility Considerations

Motion can cause discomfort for users with vestibular disorders. Always respect the prefers-reduced-motion preference.

CSS approach

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.01ms !important;
  }
}

JavaScript approach

function safeTransition(updateCallback) {
  const prefersReduced = window.matchMedia(
    '(prefers-reduced-motion: reduce)'
  ).matches;

  if (!document.startViewTransition || prefersReduced) {
    updateCallback();
    return;
  }

  document.startViewTransition(updateCallback);
}

Additional accessibility guidelines:

14. Fallback Strategies

The View Transitions API is designed to be progressive. If the browser does not support it, document.startViewTransition is undefined, and you simply execute the DOM update without animation.

// Universal pattern that works everywhere
function updateWithTransition(updateFn) {
  if (!document.startViewTransition) {
    updateFn();
    return Promise.resolve();
  }

  const transition = document.startViewTransition(updateFn);
  return transition.finished;
}

For cross-document transitions, unsupported browsers perform a normal page navigation. No JavaScript fallback is needed — the page simply loads without animation.

Feature detection in CSS

/* Only set view-transition-name if supported */
@supports (view-transition-name: test) {
  .hero-image {
    view-transition-name: hero;
  }
}

/* Alternative: check for the at-rule */
@supports at-rule(@view-transition) {
  @view-transition {
    navigation: auto;
  }
}

15. Best Practices

  1. Start with the default crossfade. The built-in animation is smooth and works well for most cases. Only customize when you have a specific design goal.
  2. Name only the elements that matter. Do not assign view-transition-name to every element. Pick the 2–3 key elements that create continuity between states (hero images, headings, thumbnails).
  3. Always feature-detect. Wrap document.startViewTransition() in a check. The DOM update must always happen, whether or not the animation fires.
  4. Respect reduced motion. This is not optional. Users who set this preference may experience physical discomfort from animations.
  5. Use transition types for direction. Forward/backward navigation should feel directionally correct. Use types to differentiate slide directions.
  6. Test cross-document transitions on real servers. Cross-document transitions require same-origin navigations. They will not work with file:// URLs or across different origins.
  7. Clean up dynamic names after transitions finish. Leftover view-transition-name values can cause uniqueness violations on subsequent transitions.
  8. Keep callback work minimal. The time between old and new snapshots is a visible freeze. Move heavy computation outside the callback.
  9. Combine with view-transition-class when you have many uniquely named elements that share the same animation style.
  10. Use CSS Grid or Flexbox for layouts that transition. View Transitions work best when the browser can clearly calculate before/after positions. See our CSS Grid Complete Guide for layout fundamentals.

Frequently Asked Questions

What are CSS View Transitions and how do they work?

CSS View Transitions are a browser-native API that creates smooth animated transitions between DOM states. When you call document.startViewTransition(), the browser captures a screenshot of the current state, applies your DOM changes, then animates between the old and new snapshots using CSS animations. The API generates pseudo-elements (::view-transition-old and ::view-transition-new) that you can style with standard CSS to customize the animation. This works for both same-document SPA navigations and cross-document MPA navigations.

What is the difference between same-document and cross-document view transitions?

Same-document view transitions use document.startViewTransition() in JavaScript to animate DOM changes within a single page, ideal for SPAs. Cross-document view transitions work across full page navigations in multi-page applications by adding the @view-transition CSS at-rule to both the source and destination pages. Cross-document transitions are triggered automatically by the browser during same-origin navigations and require no JavaScript, only CSS declarations and view-transition-name properties on elements you want to animate between pages.

Which browsers support the View Transitions API in 2026?

As of 2026, same-document view transitions (document.startViewTransition) are supported in Chrome 111+, Edge 111+, Safari 18+, and Firefox 133+. Cross-document view transitions have support in Chrome 126+, Edge 126+, and Safari 18.2+, with Firefox support in development. You should always use feature detection with if (document.startViewTransition) for same-document transitions. Provide graceful fallbacks since the DOM update still happens even without animation support.

How do I customize View Transition animations with CSS?

View Transitions generate a tree of pseudo-elements that you can style with CSS. The ::view-transition-old(name) pseudo-element represents the old state snapshot and ::view-transition-new(name) represents the new state. You can apply custom @keyframes animations, change duration and easing via animation properties, and use the ::view-transition-group(name) pseudo-element to control the container. For example, you can create slide, scale, or fade animations by targeting these pseudo-elements. Use the wildcard * as the transition name to style all transitions at once.

How should I handle View Transition accessibility and reduced motion preferences?

Always respect the prefers-reduced-motion media query when using View Transitions. Wrap your transition calls in a check: if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) before calling startViewTransition, or use CSS to disable animations with @media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }. This ensures users who are sensitive to motion still get the DOM update without the animation. Also keep transitions short (under 500ms) and avoid extreme movement for all users.

Related Resources

CSS Grid Complete Guide
Master two-dimensional layouts with CSS Grid
CSS :has() Selector Guide
The parent selector CSS developers always wanted
CSS Animations Complete Guide
Transitions, keyframes, and animation best practices
CSS Custom Properties Guide
Custom properties, theming, and dynamic styles