Animate page changes with CSS view transitions

One function call makes the browser animate from the old look of the page to the new one. CSS then decides what moves, how long it takes and what to do for people who turn motion off.

A view transition animates a change to the page. You put the change inside document.startViewTransition(). The browser takes a picture of the page before, runs your change, then animates from the old picture to the new layout.

CSS controls which elements move and how. Where a CSS transition animates one property, a view transition animates the whole before and after.

Try it. Open the card and close it again.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>View transition: open a card</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #1d4ed8; color: #fff; cursor: pointer; }
  .stage { position: relative; height: 240px; margin-top: 14px; }
  .card {
    view-transition-name: card;          /* this box moves and resizes on its own */
    position: absolute; left: 0; top: 0; width: 140px; height: 96px;
    border-radius: 12px; overflow: hidden; background: #fff;
    box-shadow: 0 6px 18px rgba(0, 0, 0, .12);
  }
  .card .pic { height: 56px; background: linear-gradient(135deg, #f59e0b, #db2777); }
  .card h3 { view-transition-name: card-title; margin: 8px 12px; font-size: 15px; width: max-content; }
  .card p { display: none; margin: 0 12px; font-size: 14px; line-height: 1.45; color: #4b5563; }
  /* the open state: a different size and place */
  .card.open { width: 100%; height: 240px; }
  .card.open .pic { height: 120px; }
  .card.open p { display: block; }
</style>
</head>
<body>
<button id="toggle">Open the card</button>
<div class="stage">
  <div class="card" id="card">
    <div class="pic"></div>
    <h3>Sunset trip</h3>
    <p>The browser took a picture of the small card, applied the change, then animated from the old picture to the new layout.</p>
  </div>
</div>

<script>
  const card = document.getElementById('card');
  const btn = document.getElementById('toggle');

  function update() {
    const open = card.classList.toggle('open');
    btn.textContent = open ? 'Close the card' : 'Open the card';
  }

  btn.addEventListener('click', () => {
    // Fallback: browsers without the API just change instantly
    if (!document.startViewTransition) { update(); return; }
    document.startViewTransition(update);
  });
</script>
</body>
</html>
A card that opens and closes. The only animation code is one startViewTransition call and one view-transition-name.

There are no keyframes in that example and no measuring of positions. The card has view-transition-name: card, so the browser moves and resizes it from its old box to its new one. The code that makes it happen is short:

btn.addEventListener('click', () => {
  if (!document.startViewTransition) { update(); return; } // no support: change instantly
  document.startViewTransition(update);
});

How startViewTransition works

startViewTransition takes one function, the update. That function changes the DOM however you like: toggle a class, reorder a list, swap text. The browser wraps it in four steps.

What the browser does around your update function.
What the browser does around your update function.
  1. Capture the old state. Every element with a view-transition-name is saved as a picture. The whole page is captured too, under the name root.
  2. Run your update. Rendering waits while it runs, so nobody sees a half-finished change. If the function returns a promise, the browser waits for it.
  3. Read the new state. The browser notes where each named element is now and how big it is.
  4. Animate. Pseudo-elements laid over the page move, resize and cross-fade from old to new. The default takes 0.25 seconds. Then they are removed and the real page shows again.

The call returns a ViewTransition object with three promises: updateCallbackDone, ready (the animation is about to start) and finished. Its skipTransition() method jumps straight to the end.

view-transition-name decides what moves

Without names, only root is captured. The old page and the new page cross-fade in place, and nothing travels across the screen. Try the three modes below with Shuffle.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>View transition: with and without names</title>
<style>
  body { margin: 0; padding: 14px 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  fieldset { border: 0; padding: 0; margin: 0 0 10px; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 14px; }
  button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #1d4ed8; color: #fff; cursor: pointer; }
  ul { list-style: none; padding: 0; margin: 12px 0 0; display: grid; gap: 6px; }
  li { padding: 9px 12px; border-radius: 8px; color: #fff; font-weight: 600; }
  /* slow every group down a little so the difference is easy to see */
  ::view-transition-group(*) { animation-duration: .6s; }
</style>
</head>
<body>
<fieldset>
  <label><input type="radio" name="mode" value="none"> No transition</label>
  <label><input type="radio" name="mode" value="page"> startViewTransition only</label>
  <label><input type="radio" name="mode" value="named" checked> + view-transition-name</label>
</fieldset>
<button id="shuffle">Shuffle</button>
<ul id="list">
  <li data-id="1" style="background:#2563eb">1 · Blue</li>
  <li data-id="2" style="background:#16a34a">2 · Green</li>
  <li data-id="3" style="background:#d97706">3 · Amber</li>
  <li data-id="4" style="background:#db2777">4 · Pink</li>
  <li data-id="5" style="background:#7c3aed">5 · Violet</li>
</ul>

<script>
  const list = document.getElementById('list');
  const items = [...list.children];

  function shuffle() {
    const order = [...list.children];
    order.push(order.shift());   // move the first item to the end
    order.reverse();             // then flip the order
    list.append(...order);
  }

  document.getElementById('shuffle').addEventListener('click', () => {
    const mode = document.querySelector('input[name=mode]:checked').value;
    // each item needs its own, unique name to move by itself
    items.forEach((li) => {
      li.style.viewTransitionName = mode === 'named' ? 'item-' + li.dataset.id : 'none';
    });
    if (mode === 'none' || !document.startViewTransition) { shuffle(); return; }
    document.startViewTransition(shuffle);
  });
</script>
</body>
</html>
The same shuffle three ways: no transition, startViewTransition alone, and with one name per item.
Left: only the page is captured, so it blends. Right: each named item gets its own group and slides.
Left: only the page is captured, so it blends. Right: each named item gets its own group and slides.

Each name must be unique among the elements on screen. If two visible elements share a name, the browser skips the whole transition with an InvalidStateError.

Your update still runs, so the page ends up correct, just without animation. In a list, build the name from an id:

li.style.viewTransitionName = 'item-' + li.dataset.id;
Value What it does
none (default) Not captured on its own. It is part of the root picture.
root Given to the <html> element by the browser. The page-wide cross-fade.
Any other name, such as card Captured separately and animated as its own group. Must be unique on screen.

Style it with ::view-transition-old and ::view-transition-new

During the animation, the browser builds a small tree of pseudo-elements for each name. You can select them in CSS like any other element.

The pseudo-elements for one name. The group moves, the old picture fades out, the new state fades in.
The pseudo-elements for one name. The group moves, the old picture fades out, the new state fades in.

The group carries the move and the resize. Change its timing to slow every move down:

::view-transition-group(*) { animation-duration: .6s; }

The old and new parts carry the fade. Replace their animation to get a different effect, for example a slide:

@keyframes slide-out { to { transform: translateX(-40px); opacity: 0; } }
@keyframes slide-in { from { transform: translateX(40px); opacity: 0; } }

::view-transition-old(root) { animation: slide-out .3s ease-in both; }
::view-transition-new(root) { animation: slide-in .3s ease-out both; }

These are ordinary CSS animations, so the rules from CSS keyframes apply: name, duration, timing function and fill mode.

Style many elements at once with view-transition-class

A gallery might have 50 tiles, each with its own name. Writing 50 selectors is not practical. view-transition-class gives them a shared class for these pseudo-elements only:

.tile { view-transition-class: tile; }
::view-transition-group(.tile) { animation-duration: .4s; }

The names still have to be unique. The class is only for styling. It is newer than the rest of the API. Where it is not supported, a selector such as ::view-transition-group(.tile) is invalid, the rule is dropped and the default animation plays instead.

Elements that appear or disappear

When an element is hidden by the update, it only has an old picture. When it appears, it only has a new one. In both cases that picture is the only child of its image pair, and :only-child can target it:

::view-transition-new(.tile):only-child { animation: pop-in .3s ease-out both; }
::view-transition-old(.tile):only-child { animation: pop-out .2s ease-in both; }

An element with display: none is not captured at all. That is why hiding a tile works, and why a hidden element does not clash with a visible one that uses the same name.

A fallback when the browser does not support it

View transitions are a newer feature, so check current browser support for the browsers your readers use. Where the method is missing, document.startViewTransition is undefined and calling it throws. Test for it and run the update directly:

function withTransition(update) {
  if (!document.startViewTransition) { update(); return; }
  document.startViewTransition(update);
}

The page then changes instantly, which is exactly what it did before you added the animation. CSS rules for the pseudo-elements are simply never used there.

Reduced motion

Some people turn on reduce motion in their system settings because movement on screen makes them feel unwell. Respect it by keeping the change and dropping the movement:

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

With these rules there are no animations, and the transition finishes at once. If you prefer to handle it in the script, check matchMedia('(prefers-reduced-motion: reduce)').matches and call the update directly when it is true.

This gallery puts everything together. Tiles that stay slide to their new spot, tiles that leave shrink away, and tiles that come back pop in. It has the fallback, and with reduced motion on, the grid changes without animation.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>View transition: filtered gallery</title>
<style>
  body { margin: 0; padding: 14px 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .filters { display: flex; gap: 6px; flex-wrap: wrap; }
  .filters button { font: inherit; padding: 7px 13px; border: 1px solid #cbd5e1; border-radius: 99px; background: #fff; color: #1d2330; cursor: pointer; }
  .filters button[aria-pressed="true"] { background: #1d2330; border-color: #1d2330; color: #fff; }
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; margin-top: 12px; }
  .tile { view-transition-class: tile; height: 84px; border-radius: 10px; display: flex; align-items: flex-end;
          padding: 6px 8px; color: #fff; font-size: 13px; font-weight: 600; box-sizing: border-box; }
  .tile[hidden] { display: none; }
  .note { font-size: 13px; color: #4b5563; margin: 10px 0 0; }
  .note .rm { display: none; }

  /* one rule styles every tile, whatever its name */
  ::view-transition-group(.tile) { animation-duration: .4s; }
  /* a tile that only exists after the change: it is appearing */
  ::view-transition-new(.tile):only-child { animation: pop-in .3s ease-out both; }
  /* a tile that only existed before the change: it is leaving */
  ::view-transition-old(.tile):only-child { animation: pop-out .2s ease-in both; }
  @keyframes pop-in  { from { opacity: 0; transform: scale(.6); } }
  @keyframes pop-out { to   { opacity: 0; transform: scale(.6); } }

  /* reduced motion: keep the change, drop the movement */
  @media (prefers-reduced-motion: reduce) {
    ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
    .note .rm { display: inline; }
  }
</style>
</head>
<body>
<div class="filters">
  <button data-f="all" aria-pressed="true">All</button>
  <button data-f="warm" aria-pressed="false">Warm</button>
  <button data-f="cool" aria-pressed="false">Cool</button>
</div>
<div class="grid" id="grid">
  <div class="tile" data-t="warm" style="background:linear-gradient(135deg,#f59e0b,#dc2626)">Ember</div>
  <div class="tile" data-t="cool" style="background:linear-gradient(135deg,#06b6d4,#2563eb)">Lagoon</div>
  <div class="tile" data-t="warm" style="background:linear-gradient(135deg,#fb7185,#e11d48)">Coral</div>
  <div class="tile" data-t="cool" style="background:linear-gradient(135deg,#34d399,#0d9488)">Mint</div>
  <div class="tile" data-t="warm" style="background:linear-gradient(135deg,#fbbf24,#ea580c)">Amber</div>
  <div class="tile" data-t="cool" style="background:linear-gradient(135deg,#a78bfa,#4f46e5)">Iris</div>
  <div class="tile" data-t="warm" style="background:linear-gradient(135deg,#f472b6,#be185d)">Rose</div>
  <div class="tile" data-t="cool" style="background:linear-gradient(135deg,#7dd3fc,#0369a1)">Glacier</div>
</div>
<p class="note">Pick a filter. Tiles that stay slide into place, the others shrink away or pop in.<span class="rm"> Reduce motion is on, so the grid changes without animation.</span></p>

<script>
  const tiles = [...document.querySelectorAll('.tile')];
  const buttons = [...document.querySelectorAll('.filters button')];

  // a unique name per tile; the shared class is set in the CSS
  tiles.forEach((t, i) => { t.style.viewTransitionName = 'tile-' + i; });

  function applyFilter(f) {
    tiles.forEach((t) => { t.hidden = f !== 'all' && t.dataset.t !== f; });
    buttons.forEach((b) => b.setAttribute('aria-pressed', b.dataset.f === f));
  }

  buttons.forEach((b) => b.addEventListener('click', () => {
    if (!document.startViewTransition) { applyFilter(b.dataset.f); return; }
    document.startViewTransition(() => applyFilter(b.dataset.f));
  }));
</script>
</body>
</html>
Unique names per tile, one view-transition-class for styling, :only-child for tiles that leave or arrive, and a reduced-motion rule.
  • Names: the script gives each tile tile-0, tile-1 and so on, once, at the start.
  • Class: view-transition-class: tile in the CSS lets three rules style every tile.
  • Fast clicks: starting a new transition skips the one in progress. The page always ends on the last filter chosen.

The layout itself is a normal CSS grid, as in the HTML image gallery guide. The transition only animates between its states.

When it does not work

What you see Cause Fix
The whole page fades, nothing moves The element has no view-transition-name Give it a unique name
The change happens with no animation at all Two visible elements share a name, so the transition was skipped Make every name unique, such as item- plus an id
startViewTransition is not a function The browser does not support the API Check for the method and call the update directly
::view-transition-group(.tile) has no effect view-transition-class is not set or not supported Set it on the elements, or style each name
Clicks during the animation do nothing The pseudo-element overlay sits on top of the page until it ends Keep durations short
An animation jumps to its end A new transition started before it finished Expected. The newest change wins

A view transition is hard to show in a screenshot, because it only exists while it moves. A screen recording loses the part where the other person clicks.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can click the filters and watch the tiles move themselves.

If you change the code later, the same link shows the new version.

Questions people ask

Do view transitions need JavaScript?

Within one page, yes: the change has to happen inside document.startViewTransition(). Everything about how it looks is CSS. Transitions between two separate pages use a different opt-in, the @view-transition { navigation: auto; } rule, and are not covered in this guide.

Why does my whole page fade instead of the element moving?

The element has no view-transition-name, so only the page as a whole (named root) is captured and cross-faded. Give the element a unique name, and it gets its own group that moves and resizes.

What happens in a browser that does not support view transitions?

document.startViewTransition is undefined there, so calling it throws. Check for it first and run the update function directly when it is missing. The change still happens, just without the animation.

Can two elements share a view-transition-name?

Not at the same time. If two rendered elements have the same name, the transition is skipped with an InvalidStateError, although your DOM change still runs. An element with display: none is not captured, so it does not count. Use view-transition-class to style many elements together.

How do I turn view transitions off for reduced motion?

Inside @media (prefers-reduced-motion: reduce), set animation: none on ::view-transition-group(*), ::view-transition-old(*) and ::view-transition-new(*). The change then appears at once. You can also check matchMedia in the script and skip startViewTransition.

Keep reading