The View Transitions API
By Flavio Copes
Create smooth same-document animations with the View Transitions API using startViewTransition, CSS pseudo-elements, and view-transition-name on elements.
The View Transitions API animates a page from one visual state to another.
The browser captures the old state, updates the DOM, captures the new state, and animates between the two images. You do not need to keep two copies of the interface in the DOM or calculate every movement yourself.
This is useful for tabs, filtered lists, image galleries, and page navigations. It also works as progressive enhancement: the content still changes when the animation is unavailable.
Start with a small same-document transition
Imagine a page with two buttons and a panel:
<nav>
<button data-view="inbox">Inbox</button>
<button data-view="archive">Archive</button>
</nav>
<main id="messages"></main>
The page already has a function that updates the panel:
function showMessages(view) {
const messages = view === 'inbox'
? ['Welcome', 'Your receipt']
: ['Old newsletter']
document.querySelector('#messages').innerHTML = messages
.map(message => `<article>${message}</article>`)
.join('')
}
Wrap that update in document.startViewTransition():
document.querySelector('nav').addEventListener('click', event => {
const button = event.target.closest('button')
if (!button) return
document.startViewTransition(() => {
showMessages(button.dataset.view)
})
})
Without extra CSS, the browser applies a short crossfade. The real DOM changes inside the callback. The transition is only a visual layer over that change.
This separation is the important part. Keep your state update working first. Add the animation around it afterward.
Add a fallback
Do not make the DOM update depend on animation support.
Create a small helper:
function transition(update) {
if (!document.startViewTransition) {
update()
return
}
document.startViewTransition(update)
}
Now the click handler becomes:
transition(() => {
showMessages(button.dataset.view)
})
Older browsers run showMessages() immediately. Modern browsers animate the change.
What the browser creates
During the transition, the browser creates a temporary tree of CSS pseudo-elements. The most useful ones are:
::view-transition-old(root)for the captured old page::view-transition-new(root)for the captured new page::view-transition-group(root)for the layer containing both
You can change the default timing with CSS:
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 250ms;
animation-timing-function: ease-out;
}
The pseudo-elements exist only while the animation runs. Your page does not keep these duplicate states in its normal DOM.
Animate one element independently
A full-page crossfade is useful, but shared elements make transitions feel connected.
Suppose a product thumbnail becomes the large image on a detail view. Give both versions the same view-transition-name:
.product-image {
view-transition-name: product-image;
}
The old and new elements do not need to be the same DOM node. They need to represent the same visual object before and after the update.
Customize that named transition:
::view-transition-group(product-image) {
animation-duration: 400ms;
animation-timing-function: ease-in-out;
}
The browser animates the captured image between its old and new size and position.
Every active view-transition-name must be unique. If two visible elements use product-image at the same time, the transition can be skipped. Assign names only to the elements that need their own layer.
Wait for the DOM update
The callback can return a promise. The browser waits for it before capturing the new state.
const transition = document.startViewTransition(async () => {
const response = await fetch('/api/messages')
const messages = await response.json()
renderMessages(messages)
})
try {
await transition.updateCallbackDone
} catch (error) {
console.error('Could not update messages', error)
}
Be careful with slow work here. The browser pauses the visual update while the callback runs. Fetch data first when possible, then start the transition around the quick DOM change.
The returned object exposes three useful promises:
updateCallbackDonesettles when your update callback finishesreadysettles when the new state is captured and animation can beginfinishedsettles when the transition ends
Most interfaces only need the callback. Use these promises when later work depends on a specific transition phase.
Cross-document transitions
The same idea can animate navigation between two pages on the same origin.
Opt both pages in with CSS:
@view-transition {
navigation: auto;
}
Normal links keep working:
<a href="/products/coffee-grinder/">View product</a>
When the browser supports cross-document transitions, it captures the old page and the destination page. Without support, the link performs a normal navigation.
Use view-transition-name on matching elements across both documents to create a shared-element effect. The names must match, and both pages must opt in.
Cross-document support still differs between browsers. Check current compatibility before making the effect central to your design.
If you use Astro, read Astro view transitions and dark mode for a framework-level setup.
Respect reduced motion
Animations are enhancements. Some users ask the operating system to reduce them.
Disable the transition animations for that preference:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
The DOM still updates. Only the movement disappears.
Common mistakes
The first mistake is starting with a complex animation. Begin with the default crossfade. Add one named element after the state change works correctly.
The second is doing slow network work inside the callback. Prepare the data first, then keep the visual update short.
The third is treating the snapshot as live UI. Users interact with the updated document, not the old captured image. Do not use a long transition to hide an incomplete state.
My advice is to use view transitions where they explain continuity: a selected card opening, a tab changing, or a filtered list rearranging. Skip them when movement adds decoration but no meaning.
Related posts about platform: