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:
- Same-document transitions — animate DOM changes within a single page (ideal for SPAs)
- Cross-document transitions — animate across full page navigations (ideal for MPAs)
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:
- Chrome 111+ (March 2023)
- Edge 111+ (March 2023)
- Safari 18+ (September 2024)
- Firefox 133+ (November 2024)
Cross-document view transitions have slightly narrower support:
- Chrome 126+ (June 2024)
- Edge 126+ (June 2024)
- Safari 18.2+ (December 2024)
- Firefox — in development
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:
- The browser captures the old state as a screenshot
- Your callback runs, updating the DOM
- The browser captures the new state
- 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:
::view-transition— the root overlay covering the viewport::view-transition-group(name)— animates size and position from old to new::view-transition-image-pair(name)— contains the two snapshots, handles isolation::view-transition-old(name)— the old state screenshot (fades out by default)::view-transition-new(name)— the new state screenshot (fades in by default)
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:
- Each name must be unique on the page at the time of the transition. Two visible elements cannot share the same name.
- The value
none(the default) means the element participates in the root crossfade. - The value
autogenerates a unique name automatically (useful when combined with view-transition-class). - Names are case-sensitive custom identifiers (like CSS animation names).
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
- Keep transitions under 500ms. The browser composites the pseudo-elements on the GPU, but long transitions hold snapshots in memory. Aim for 200–400ms for most transitions.
- Limit named elements. Each
view-transition-namecreates its own snapshot pair. Naming 50 elements means 100 raster images in memory during the transition. - Avoid large DOM updates in the callback. The browser waits for your callback to finish before capturing the new state. Heavy synchronous work delays the animation start.
- Use
contain: paintorcontain: layouton transitioned elements to help the browser optimize snapshot capture. - Clean up dynamic names. If you assign
view-transition-namevia JavaScript, remove them after the transition finishes to avoid uniqueness conflicts on the next transition. - Avoid animating during transitions. Running CSS animations or JavaScript-driven animation simultaneously with a view transition can cause jank. Pause ongoing animations before starting a transition.
// 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:
- Keep transitions short — under 400ms for navigations
- Avoid spinning, bouncing, or parallax effects in transitions
- Ensure focus management still works correctly after the DOM update
- Test with screen readers — the transition should not interrupt announcements
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
- 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.
- Name only the elements that matter. Do not assign
view-transition-nameto every element. Pick the 2–3 key elements that create continuity between states (hero images, headings, thumbnails). - Always feature-detect. Wrap
document.startViewTransition()in a check. The DOM update must always happen, whether or not the animation fires. - Respect reduced motion. This is not optional. Users who set this preference may experience physical discomfort from animations.
- Use transition types for direction. Forward/backward navigation should feel directionally correct. Use
typesto differentiate slide directions. - 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.
- Clean up dynamic names after transitions finish. Leftover
view-transition-namevalues can cause uniqueness violations on subsequent transitions. - Keep callback work minimal. The time between old and new snapshots is a visible freeze. Move heavy computation outside the callback.
- Combine with
view-transition-classwhen you have many uniquely named elements that share the same animation style. - 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.