# The View Transitions API

> Create smooth same-document animations with the View Transitions API using startViewTransition, CSS pseudo-elements, and view-transition-name on elements.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-20 | Updated: 2026-08-03 | Topics: [Web Platform](https://flaviocopes.com/tags/platform/) | Canonical: https://flaviocopes.com/view-transitions-api/

The **View Transitions API** animates a page from one visual state to another.

The browser captures the old state, updates the [DOM](https://flaviocopes.com/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:

```html
<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:

```js
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()`:

```js
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:

```js
function transition(update) {
  if (!document.startViewTransition) {
    update()
    return
  }

  document.startViewTransition(update)
}
```

Now the click handler becomes:

```js
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:

```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`:

```css
.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:

```css
::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.

```js
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:

- `updateCallbackDone` settles when your update callback finishes
- `ready` settles when the new state is captured and animation can begin
- `finished` settles 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:

```css
@view-transition {
  navigation: auto;
}
```

Normal links keep working:

```html
<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](https://caniuse.com/view-transitions) before making the effect central to your design.

If you use Astro, read [Astro view transitions and dark mode](https://flaviocopes.com/astro-view-transitions-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:

```css
@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.
