Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 16 – Transitions, animations, scroll and view transitions

View transitions for pages and components

Animate between states with startViewTransition() and view-transition-name, restyle the default cross-fade, and opt in to transitions between pages.

  • Advanced
  • 30 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Run a same-document view transition with document.startViewTransition() and view-transition-name
  • Change the default cross-fade with ::view-transition-group(), -old() and -new(), view-transition-class and types
  • Explain why a transition is skipped, and what still happens to the page when it is
  • Set up cross-document transitions with @view-transition where browsers support them, and plain navigation elsewhere

Before you start

What you’ll learn · Before you start

What you will learn

  • Run a same-document view transition with document.startViewTransition() and view-transition-name
  • Change the default cross-fade with ::view-transition-group(), -old() and -new(), view-transition-class and types
  • Explain why a transition is skipped, and what still happens to the page when it is
  • Set up cross-document transitions with @view-transition where browsers support them, and plain navigation elsewhere

Before you start

On this page

A view transition animates a page from one state to the next, such as a product card growing into its detail view, without you working out any positions yourself. The browser takes a snapshot of the page, lets your code change the DOM, takes a second snapshot, and animates between the two: by default the whole page cross-fades in a quarter of a second. Give an element a view-transition-name and it gets its own animation instead: it moves and resizes from its old box to its new one, while the rest fades. Within one page, document.startViewTransition() starts it, and that is Baseline newly available. Between two pages of the same site, a @view-transition rule in both opts in; that works in Chrome 126 and Safari 18.2 or later, and Firefox navigates as usual. Every animation listed here came from document.getAnimations() in three engines (Chromium 153, Firefox 155 and WebKit 26.6), so you can see exactly what the browser builds before restyling it.

How a same-document transition runs

The six steps of a same-document view transition, from startViewTransition() to finished, and when it is skipped.1. document.startViewTransition(update)2. Capture the old state: a snapshotof the root and of every elementwith a view-transition-name3. Rendering pauses; update()changes the DOM(updateCallbackDone resolves)4. Capture the new state and buildthe ::view-transition pseudo-elements(ready resolves)skipped instead if a name is used twice,the page is hidden or update() failed:ready rejects and nothing animates5. Animate: groups move and resize,old images fade out, new ones fade in;clicks go to the root meanwhile6. The pseudo-elements are removedand the real page shows(finished resolves)

The steps of a same-document view transition

Text description of the diagram

The diagram shows six steps in a column.

  1. A script calls document.startViewTransition(update).
  2. The browser captures the old state: a snapshot of the root and of every element with a view-transition-name.
  3. Rendering pauses while update() changes the DOM. When it has finished, the updateCallbackDone promise resolves.
  4. The browser captures the new state and builds the ::view-transition pseudo-elements, and the ready promise resolves. The transition is skipped instead if a name is used twice, the page is hidden or update() failed: then ready rejects and nothing animates, although update() has still run.
  5. The animations run: groups move and resize, old images fade out and new ones fade in. Meanwhile, clicks go to the root element.
  6. The pseudo-elements are removed and the real page shows. The finished promise resolves.

document.startViewTransition(update) returns a ViewTransition with three promises: updateCallbackDone when your update function has changed the DOM, ready when the pseudo-elements exist and the animations are about to start, and finished when everything is over. While update runs, the page stops rendering, so that the new state can be captured in one piece. This page measures what that costs:

A slow update, and one that never finishes HTML · slow-update.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>What a slow update does to a view transition</title>
</head>
<body>
  <p id="message">Old content</p>
  <script>
    (async () => {
      // 1. An update that takes 1000 ms, like a fetch inside the callback: count the frames the page draws meanwhile.
      let frames = 0;
      let counting = true;
      const tick = () => { if (counting) { frames++; requestAnimationFrame(tick); } };
      requestAnimationFrame(tick);
      await new Promise((r) => setTimeout(r, 200));
      const before = frames;
      const slow = document.startViewTransition(() => new Promise((resolve) => {
        setTimeout(() => { document.getElementById('message').textContent = 'New content'; resolve(); }, 1000);
      }));
      await slow.updateCallbackDone;
      const during = frames - before;
      await slow.finished.catch(() => {});
      counting = false;
      console.log(`frames drawn during a 1000 ms update: ${during}`);

      // 2. An update that never finishes: how long until the browser gives up on the transition?
      const start = performance.now();
      const stuck = document.startViewTransition(() => new Promise(() => {}));
      const result = await Promise.race([
        stuck.ready.then(() => 'it ran', (error) => `${error.name} after ${Math.round((performance.now() - start) / 100) / 10} s`),
        new Promise((r) => setTimeout(() => r('still waiting after 12 s'), 12000)),
      ]);
      console.log(`an update that never finishes: ${result}`);
    })();
  </script>
</body>
</html>

With an update that took 1,000 ms, as a network request inside it would, each of the three engines drew a single frame in that second: the old page stayed frozen on screen. An update that never finished was abandoned with a TimeoutError after 4 seconds in Chromium and WebKit and after about 10.6 seconds in Firefox. So fetch data first, then start the transition with a function that only swaps the DOM.

A view transition is an enhancement around a change that must happen anyway, and the API is built that way: even when the transition is skipped, update is still called. Feature detection is one line, so older browsers simply get the change without the animation: if (!document.startViewTransition) { update(); } else { document.startViewTransition(update); }.

Naming the parts that move: view-transition-name

The root element is named root by the browser. Each element you give another view-transition-name is captured separately, and if the same name exists in both states, its group animates the change of position and size. A product grid can name the card’s photo and title so they travel into the detail view:

Naming the photo and the title, with longer groups and a reduced-motion branch CSS · product.css
/* A product card that grows into its detail view: the photo and the title move and resize, the rest cross-fades. */
.product-photo { view-transition-name: product-photo; }
.product-title { view-transition-name: product-title; }

/* The group pseudo-elements move and resize each named part from its old box to its new one. */
::view-transition-group(product-photo),
::view-transition-group(product-title) {
  animation-duration: 350ms;
  animation-timing-function: ease-in-out;
}

/* The rest of the page, the root, keeps the default cross-fade, a little quicker. */
::view-transition-group(root) {
  animation-duration: 200ms;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;               /* the new state appears at once */
  }
}
The grid turning into the detail view, with its animations listed HTML · product.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A list-to-detail view transition, measured</title>
  <link rel="stylesheet" href="product.css">
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .grid { display: grid; grid-template-columns: repeat(3, 160px); gap: 16px; padding: 16px; }
    .card img, .detail img { display: block; inline-size: 100%; aspect-ratio: 1; background: #fde68a; }
    .detail { display: grid; grid-template-columns: 320px 1fr; gap: 24px; padding: 16px; }
  </style>
</head>
<body>
  <main id="view">
    <div class="grid">
      <a class="card" href="#kettle"><img class="product-photo" alt="Steel kettle" src="data:,"><h2 class="product-title">Steel kettle</h2></a>
      <a class="card" href="#toaster"><img alt="Toaster" src="data:,"><h2>Toaster</h2></a>
      <a class="card" href="#mixer"><img alt="Hand mixer" src="data:,"><h2>Hand mixer</h2></a>
    </div>
  </main>
  <template id="detail">
    <article class="detail">
      <img class="product-photo" alt="Steel kettle" src="data:,">
      <div><h1 class="product-title">Steel kettle</h1><p>1.7 litres, boils in about 3 minutes.</p></div>
    </article>
  </template>
  <script>
    const view = document.getElementById('view');
    const showDetail = () => view.replaceChildren(document.getElementById('detail').content.cloneNode(true));
    const names = (a) => `${a.effect.pseudoElement} ${a.animationName} ${a.effect.getComputedTiming().duration} ms`;
    if (!document.startViewTransition) {
      showDetail();                                   // no view transitions: change the page without animation
      console.log('no startViewTransition: the detail view appeared at once');
    } else {
      const transition = document.startViewTransition(showDetail);
      transition.ready.then(() => {
        const animations = document.getAnimations().filter((a) => a.effect.pseudoElement).map(names).sort();
        console.log(`${animations.length} animations:\n  ${animations.join('\n  ')}`);
      }, (error) => console.log(`skipped: ${error.name}`));
      transition.finished.then(() => console.log(`finished; the detail view is in place: ${!!view.querySelector('.detail')}`));
    }
  </script>
</body>
</html>

All three engines started the same 15 animations, five for each of the three names:

Pseudo-element What its animations do Duration
::view-transition-group(product-photo) move and resize from the old box to the new 350 ms
::view-transition-old(product-photo) fade out, blended with plus-lighter 350 ms
::view-transition-new(product-photo) fade in, blended with plus-lighter 350 ms
the same three for product-title as above 350 ms
the same three for root as above 200 ms

The durations are the stylesheet’s, and the rest are the browser’s defaults, with names such as -ua-view-transition-group-anim-product-photo, -ua-view-transition-fade-out and -ua-mix-blend-mode-plus-lighter. The plus-lighter blend keeps the cross-fade from dimming where the two images overlap. This program prints the tree those pseudo-elements form, and what changes when a name exists in only one of the two states:

The pseudo-element tree for two transitions JavaScript · tree.mjs
// The pseudo-elements a view transition builds, from the names captured in the old state and in the new state
// (CSS View Transitions Level 1). Every name gets a group with an image pair inside it; the pair holds an old image
// when the name was in the old state and a new one when it is in the new state.
function tree(title, oldNames, newNames) {
  const names = [...oldNames, ...newNames.filter((n) => !oldNames.includes(n))];
  console.log(`${title}\n::view-transition`);
  names.forEach((name, i) => {
    const last = i === names.length - 1;
    const pad = last ? '    ' : '│   ';
    console.log(`${last ? '└── ' : '├── '}::view-transition-group(${name})`);
    console.log(`${pad}└── ::view-transition-image-pair(${name})`);
    const images = [];
    if (oldNames.includes(name)) images.push(`::view-transition-old(${name})`);
    if (newNames.includes(name)) images.push(`::view-transition-new(${name})`);
    images.forEach((image, j) => console.log(`${pad}    ${j === images.length - 1 ? '└── ' : '├── '}${image}`));
  });
  console.log('');
}

tree('The product card growing into its detail view:', ['root', 'product-photo', 'product-title'], ['root', 'product-photo', 'product-title']);
tree('A notice leaving while a badge arrives:', ['root', 'notice', 'cart'], ['root', 'cart', 'badge']);

Output

The product card growing into its detail view:
::view-transition
├── ::view-transition-group(root)
│   └── ::view-transition-image-pair(root)
│       ├── ::view-transition-old(root)
│       └── ::view-transition-new(root)
├── ::view-transition-group(product-photo)
│   └── ::view-transition-image-pair(product-photo)
│       ├── ::view-transition-old(product-photo)
│       └── ::view-transition-new(product-photo)
└── ::view-transition-group(product-title)
    └── ::view-transition-image-pair(product-title)
        ├── ::view-transition-old(product-title)
        └── ::view-transition-new(product-title)

A notice leaving while a badge arrives:
::view-transition
├── ::view-transition-group(root)
│   └── ::view-transition-image-pair(root)
│       ├── ::view-transition-old(root)
│       └── ::view-transition-new(root)
├── ::view-transition-group(notice)
│   └── ::view-transition-image-pair(notice)
│       └── ::view-transition-old(notice)
├── ::view-transition-group(cart)
│   └── ::view-transition-image-pair(cart)
│       ├── ::view-transition-old(cart)
│       └── ::view-transition-new(cart)
└── ::view-transition-group(badge)
    └── ::view-transition-image-pair(badge)
        └── ::view-transition-new(badge)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node tree.mjs

A name that exists only in the old state, like the notice, gets only an old image, which fades out; one that exists only in the new state, like the badge, gets only a new image, which fades in; neither gets a group animation, because there is nothing to move between. All three engines measured exactly that: one fade each.

Names must be unique, or nothing animates

Every view-transition-name must belong to one rendered element at a time. With two, the transition is skipped:

A duplicate name, then match-element and view-transition-class HTML · names.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Duplicate names, match-element and view-transition-class</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .chip { display: inline-block; padding: 4px 12px; background: #e0e7ff; }
    .twins .chip { view-transition-name: chip; }            /* the same name on two elements: not allowed */
    .auto .chip { view-transition-name: match-element; view-transition-class: chip; }
    ::view-transition-group(.chip) { animation-duration: 400ms; }   /* every group with the class chip */
  </style>
</head>
<body>
  <div class="twins" id="twins"><span class="chip">Tea</span><span class="chip">Coffee</span></div>
  <ul class="auto" id="auto"><li class="chip">Tea</li><li class="chip">Coffee</li><li class="chip">Juice</li></ul>
  <script>
    (async () => {
      // 1. Two elements share a view-transition-name.
      const twins = document.getElementById('twins');
      const first = document.startViewTransition(() => twins.append(twins.firstElementChild));
      await first.updateCallbackDone;
      const order = [...twins.children].map((c) => c.textContent).join(', ');
      await first.ready.then(() => console.log('duplicate names: the transition ran'),
        (error) => console.log(`duplicate names: ready rejected with ${error.name}; the DOM still changed to: ${order}`));
      await first.finished.then(() => console.log('duplicate names: finished fulfilled'),
        (error) => console.log(`duplicate names: finished rejected with ${error.name}`));
      twins.classList.remove('twins');              // the duplicates would make every later transition fail too

      // 2. match-element gives each list item its own name; view-transition-class styles them all at once.
      const list = document.getElementById('auto');
      const second = document.startViewTransition(() => list.prepend(list.lastElementChild));
      await second.ready.catch((error) => console.log(`match-element: ready rejected with ${error.name}`));
      const groups = document.getAnimations()
        .filter((a) => a.effect.pseudoElement?.startsWith('::view-transition-group'))
        .map((a) => `${a.effect.getComputedTiming().duration} ms`).sort();
      console.log(`match-element: ${groups.length} groups (root and one per item), durations ${groups.join(', ')}`);
      await second.finished;
    })().catch((error) => console.log(`stopped: ${error.name} ${error.message}`));
  </script>
</body>
</html>

In all three engines ready rejected with an InvalidStateError, and the DOM change happened anyway, as the specification requires. A duplicate left on the page makes every later transition fail as well, so a list of cards that all say view-transition-name: card never animates. Two fixes exist:

  • view-transition-name: match-element gives each element its own name automatically, inside one document. It is Baseline newly available. The page’s three list items got three groups.
  • view-transition-class puts several named groups under one selector, so ::view-transition-group(.chip) styled all three at once: they ran 400 ms while the root kept the default 250 ms. It is also newly available.

Restyling the animation

The pseudo-elements are styled like any other, with one twist: they live on the root, so their selectors start with ::view-transition-. ::view-transition-group(name) sets the timing for its group, and its image pair and images inherit it; ::view-transition-old(name) and ::view-transition-new(name) take your own @keyframes in place of the fades. * matches every name, and .class matches a view-transition-class. This tab switcher slides its indicator in 300 ms and cross-fades its panel in 150 ms:

A sliding tab indicator and a quick panel fade CSS · tabs.css
/* A tab switcher: the indicator slides to the new tab and the panel cross-fades. */
.tab-indicator { view-transition-name: tab-indicator; }
.tab-panel { view-transition-name: tab-panel; }

::view-transition-group(tab-indicator) {
  animation-duration: 300ms;
  animation-timing-function: ease-out;
}

::view-transition-group(tab-panel) {
  animation-duration: 150ms;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;
  }
}
The tab switcher, switched once and measured HTML · tabs.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A tab switcher with a view transition</title>
  <link rel="stylesheet" href="tabs.css">
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    [role="tablist"] { display: flex; gap: 4px; padding: 8px; }
    [role="tab"] { position: relative; padding: 8px 16px; }
    .tab-indicator { position: absolute; inset-inline: 0; inset-block-end: 0; block-size: 3px; background: #1d4ed8; }
    .tab-panel { padding: 16px; }
  </style>
</head>
<body>
  <div role="tablist" aria-label="Plans">
    <button role="tab" id="tab-1" aria-selected="true" aria-controls="panel">Monthly<span class="tab-indicator"></span></button>
    <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel" tabindex="-1">Yearly</button>
    <button role="tab" id="tab-3" aria-selected="false" aria-controls="panel" tabindex="-1">Teams</button>
  </div>
  <div class="tab-panel" id="panel" role="tabpanel" aria-labelledby="tab-1">Monthly: pay as you go.</div>
  <script>
    const panel = document.getElementById('panel');
    const indicator = document.querySelector('.tab-indicator');
    function select(tab) {
      for (const t of document.querySelectorAll('[role="tab"]')) {
        t.setAttribute('aria-selected', String(t === tab));
        t.tabIndex = t === tab ? 0 : -1;
      }
      tab.append(indicator);                         // the indicator moves to the new tab in the DOM
      panel.setAttribute('aria-labelledby', tab.id);
      panel.textContent = `${tab.textContent}: the details of this plan.`;
    }
    function switchTo(tab) {
      if (!document.startViewTransition) return select(tab);
      return document.startViewTransition(() => select(tab));
    }
    for (const tab of document.querySelectorAll('[role="tab"]')) tab.addEventListener('click', () => switchTo(tab));

    // Measure: switch to the second tab and list the pseudo-element animations, and what a click would hit.
    const transition = switchTo(document.getElementById('tab-2'));
    transition.ready.then(() => {
      const list = document.getAnimations().filter((a) => a.effect.pseudoElement)
        .map((a) => `${a.effect.pseudoElement} ${a.effect.getComputedTiming().duration} ms`).sort();
      const box = document.getElementById('tab-3').getBoundingClientRect();
      const hit = document.elementFromPoint(box.left + 5, box.top + 5);
      const motion = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduced motion' : 'full motion';
      console.log(`${motion}: ${list.length} animations${list.length ? ':\n  ' + [...new Set(list)].join('\n  ') : ''}`);
      console.log(`during the transition a click on "Teams" would hit: ${hit ? hit.localName + (hit.id ? '#' + hit.id : '') : 'nothing'}`);
    });
    transition.finished.then(() => console.log(`finished: ${document.getElementById('tab-2').getAttribute('aria-selected') === 'true' ? 'Yearly is selected' : 'not switched'}`));
  </script>
</body>
</html>

The engines agreed: groups of 300 ms for the indicator, 150 ms for the panel and 250 ms for the root. The page also asked what a click would hit in the middle of the transition. In all three engines the answer was the html element, not the “Teams” tab under the pointer: while the animation runs, captured elements do not respond to hit-testing, and the root is captured unless you set view-transition-name: none on it. Keep transitions short, a few hundred milliseconds, because the page ignores clicks for that long. The specification leaves the accessibility tree alone, so screen readers see the real DOM throughout.

Under Playwright’s reduced-motion emulation the @media (prefers-reduced-motion: reduce) block removed every animation: zero ran, and the tab still switched at once. That is the right default for large movements; a short cross-fade, which moves nothing, is usually fine to keep.

Choosing a direction with transition types

Some changes have a direction: the next step of a form should arrive from one side and the previous step from the other. startViewTransition() also takes an object, { update, types: ['forwards'] }, and while that transition runs, :active-view-transition-type(forwards) matches the root, so the stylesheet can choose keyframes by direction. Transition types are Baseline newly available.

Slides chosen by transition type, only for people who want motion CSS · steps.css
/* A two-way step-by-step form: the panel slides left when you go on and right when you go back. */
.step-panel { view-transition-name: step-panel; }

@keyframes slide-out-to-start { to { translate: -40px 0; opacity: 0; } }
@keyframes slide-in-from-end { from { translate: 40px 0; opacity: 0; } }
@keyframes slide-out-to-end { to { translate: 40px 0; opacity: 0; } }
@keyframes slide-in-from-start { from { translate: -40px 0; opacity: 0; } }

/* The type names come from the script: startViewTransition({ update, types: ['forwards'] }). People who ask for
   less motion get no slide, only the default cross-fade of the panel. */
@media (prefers-reduced-motion: no-preference) {
  :active-view-transition-type(forwards) {
    &::view-transition-old(step-panel) { animation-name: slide-out-to-start; }
    &::view-transition-new(step-panel) { animation-name: slide-in-from-end; }
  }

  :active-view-transition-type(backwards) {
    &::view-transition-old(step-panel) { animation-name: slide-out-to-end; }
    &::view-transition-new(step-panel) { animation-name: slide-in-from-start; }
  }
}
A three-step form, moved forwards and back HTML · steps.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Choosing a direction with view transition types</title>
  <link rel="stylesheet" href="steps.css">
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .step-panel { padding: 16px; }
  </style>
</head>
<body>
  <form>
    <fieldset class="step-panel" id="panel"><legend>Step 1 of 3: delivery address</legend></fieldset>
    <button type="button" id="back">Back</button>
    <button type="button" id="next">Next</button>
  </form>
  <script>
    let step = 1;
    const titles = ['delivery address', 'payment', 'review'];
    const show = () => {
      document.querySelector('#panel legend').textContent = `Step ${step} of 3: ${titles[step - 1]}`;
    };
    function go(by) {
      const update = () => { step = Math.min(3, Math.max(1, step + by)); show(); };
      if (!document.startViewTransition) return update();
      return document.startViewTransition({ update, types: [by > 0 ? 'forwards' : 'backwards'] });
    }
    document.getElementById('next').addEventListener('click', () => go(1));
    document.getElementById('back').addEventListener('click', () => go(-1));

    // Measure: go forwards, then back, and list the panel's animations each time.
    (async () => {
      for (const by of [1, -1]) {
        const transition = go(by);
        await transition.ready;
        const names = document.getAnimations()
          .filter((a) => /step-panel/.test(a.effect.pseudoElement ?? '') && !/group/.test(a.effect.pseudoElement))
          .map((a) => `${a.effect.pseudoElement.replace('::view-transition-', '')} ${a.animationName}`).sort();
        console.log(`${by > 0 ? 'Next' : 'Back'} (types: ${[...transition.types].join(', ')}): ${names.join('; ')}`);
        await transition.finished;
      }
    })();
  </script>
</body>
</html>
The button pressed The panel’s old image Its new image
Next, type forwards slide-out-to-start slide-in-from-end
Back, type backwards slide-out-to-end slide-in-from-start
either, with reduced motion the default fade-out the default fade-in

All three engines chose the keyframes by type. The list also shows a detail worth knowing: a custom animation-name replaces the browser’s whole list for that image, the plus-lighter blend included, so add mix-blend-mode: plus-lighter yourself if two overlapping images should cross-fade without dimming. Because the slides sit inside prefers-reduced-motion: no-preference, people who ask for less motion got the plain cross-fade instead.

Between pages: @view-transition

For a site of separate HTML pages, both the page you leave and the page you arrive at add the rule @view-transition { navigation: auto; }. A transition then runs on navigations within the same origin that start from the page, by following a link, sending a form, a script changing location, or going back and forward; not on reloads, on addresses typed into the address bar or bookmarks, or through a redirect to another origin. The same names, classes and pseudo-element rules apply, across the two documents:

Two pages that opt in, with a shared header

CSS · pages/site.css

/* Both pages opt in: a same-origin navigation between them, started by the user, runs a view transition. */
@view-transition {
  navigation: auto;
}

/* The site header keeps its place; only the content changes. */
.site-header { view-transition-name: site-header; }

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

HTML (index.html) · pages/index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Products</title>
  <link rel="stylesheet" href="site.css">
</head>
<body>
  <header class="site-header"><a href="index.html">Shop</a></header>
  <main><h1>Products</h1><p><a id="go" href="kettle.html">Steel kettle</a></p></main>
  <script>
    addEventListener('pageswap', (event) => console.log(`pageswap: ${event.viewTransition ? 'a view transition will run' : 'no view transition'}`));
  </script>
</body>
</html>

HTML (kettle.html) · pages/kettle.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Steel kettle</title>
  <link rel="stylesheet" href="site.css">
  <script>
    // pagereveal fires before the first frame of the new page; its viewTransition is set when one runs.
    if ('onpagereveal' in window) {
      addEventListener('pagereveal', (event) => {
        const t = event.viewTransition;
        console.log(`pagereveal: ${t ? 'a view transition runs' : 'no view transition'}`);
        t?.ready.then(() => console.log(`  animations: ${document.getAnimations().filter((a) => a.effect.pseudoElement?.startsWith('::view-transition-group')).map((a) => a.effect.pseudoElement).sort().join(', ')}`));
      });
    } else {
      console.log('this browser has no pagereveal event: the page simply loads');
    }
  </script>
</head>
<body>
  <header class="site-header"><a href="index.html">Shop</a></header>
  <main><h1>Steel kettle</h1><p>1.7 litres.</p></main>
</body>
</html>

Served from a local test server, a click on the link gave these results:

What happened Chromium and WebKit Firefox
pageswap on the old page fired, with a transition no such event
pagereveal on the new page fired, with a transition no such event
groups animated root, site-header none: a plain navigation

Cross-document transitions have limited availability for that reason: Chrome 126 and Safari 18.2 support them, and Firefox does not yet, though it supports same-document transitions. Nothing breaks there; the next page just loads. The pageswap and pagereveal events, Chromium and Safari only, let a script inspect or change the transition, for example to choose a direction with transition types on back navigations. Starting a transition on one element instead of the whole document, with element.startViewTransition(), exists only in Chromium: Firefox and WebKit reported no such method.

Practise: plan a transition

Exercise · Medium · JavaScript

Predict a view transition's default animations

Before you style a view transition, it helps to know what the browser will animate on its own. Write three functions in plan.mjs and export them.

duplicateNames(names) takes the computed view-transition-name of every element on the page and returns the names that more than one element uses, sorted, each once. none takes no part, and match-element never clashes, because the browser gives each such element its own name. A transition with a duplicate is skipped.

groupRole(name, oldNames, newNames) returns 'morph' for a name captured in both states, 'exit' for one only in the old state and 'entry' for one only in the new state.

defaultAnimations(oldNames, newNames) returns every default animation the browser starts, as strings of the form '<pseudo-element> <animation name>', for each name in either state:

- morph: ::view-transition-group(n) -ua-view-transition-group-anim-n, which moves and resizes the group; ::view-transition-old(n) -ua-view-transition-fade-out and ::view-transition-new(n) -ua-view-transition-fade-in; and both images also get -ua-mix-blend-mode-plus-lighter, which keeps the cross-fade from dimming. That is five. - exit: only ::view-transition-old(n) -ua-view-transition-fade-out. - entry: only ::view-transition-new(n) -ua-view-transition-fade-in.

The order of the list does not matter: the tests sort it. They import the three functions from plan.mjs, run in your browser, and compare with what three browser engines reported through document.getAnimations().

Starter code · plan.mjs

/** The view-transition-name values that more than one element uses, sorted. */
export function duplicateNames(names) {
  // Replace this line with your code.
  return [];
}

/** 'morph', 'exit' or 'entry'. */
export function groupRole(name, oldNames, newNames) {
  // Replace this line with your code.
  return 'morph';
}

/** Every default animation, as '<pseudo-element> <animation name>'. */
export function defaultAnimations(oldNames, newNames) {
  // Replace this line with your code.
  return [];
}
The sample tests · plan.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { duplicateNames, groupRole, defaultAnimations } from './plan.mjs';

const sorted = (list) => [...list].sort();

test('duplicate names, while none and match-element never clash', () => {
  assert.deepEqual(duplicateNames(['root', 'chip', 'chip']), ['chip']);
  assert.deepEqual(duplicateNames(['root', 'none', 'none', 'match-element', 'match-element']), []);
  assert.deepEqual(duplicateNames(['tab', 'panel', 'tab', 'panel', 'tab', 'root']), ['panel', 'tab']);
});

test('the role of each group', () => {
  const before = ['root', 'leaving', 'staying'];
  const after = ['root', 'staying', 'arriving'];
  assert.equal(groupRole('staying', before, after), 'morph');
  assert.equal(groupRole('leaving', before, after), 'exit');
  assert.equal(groupRole('arriving', before, after), 'entry');
});

test('the product card: 15 animations, as all three engines reported', () => {
  const names = ['root', 'product-photo', 'product-title'];
  assert.deepEqual(sorted(defaultAnimations(names, names)), [
    '::view-transition-group(product-photo) -ua-view-transition-group-anim-product-photo',
    '::view-transition-group(product-title) -ua-view-transition-group-anim-product-title',
    '::view-transition-group(root) -ua-view-transition-group-anim-root',
    '::view-transition-new(product-photo) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-new(product-photo) -ua-view-transition-fade-in',
    '::view-transition-new(product-title) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-new(product-title) -ua-view-transition-fade-in',
    '::view-transition-new(root) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-new(root) -ua-view-transition-fade-in',
    '::view-transition-old(product-photo) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-old(product-photo) -ua-view-transition-fade-out',
    '::view-transition-old(product-title) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-old(product-title) -ua-view-transition-fade-out',
    '::view-transition-old(root) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-old(root) -ua-view-transition-fade-out',
  ]);
});

test('names that only leave or only arrive get a single fade', () => {
  assert.deepEqual(sorted(defaultAnimations(['root', 'leaving', 'staying'], ['root', 'staying', 'arriving'])), [
    '::view-transition-group(root) -ua-view-transition-group-anim-root',
    '::view-transition-group(staying) -ua-view-transition-group-anim-staying',
    '::view-transition-new(arriving) -ua-view-transition-fade-in',
    '::view-transition-new(root) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-new(root) -ua-view-transition-fade-in',
    '::view-transition-new(staying) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-new(staying) -ua-view-transition-fade-in',
    '::view-transition-old(leaving) -ua-view-transition-fade-out',
    '::view-transition-old(root) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-old(root) -ua-view-transition-fade-out',
    '::view-transition-old(staying) -ua-mix-blend-mode-plus-lighter',
    '::view-transition-old(staying) -ua-view-transition-fade-out',
  ]);
});

test('counting for a tab switcher, and a page with nothing named', () => {
  assert.equal(defaultAnimations(['root', 'tab-indicator', 'tab-panel'], ['root', 'tab-indicator', 'tab-panel']).length, 15);
  assert.equal(defaultAnimations(['root'], ['root']).length, 5);
  assert.deepEqual(defaultAnimations(['root', 'toast'], ['root']).filter((a) => a.includes('toast')), ['::view-transition-old(toast) -ua-view-transition-fade-out']);
});
A hint

Collect the names first: the old ones, then any new ones that were not in the old state. Then decide each name's role with groupRole and push its animations. A Map or an object counting each name makes duplicateNames short.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 Two cards on the page both have view-transition-name: card. What happens when document.startViewTransition(update) runs?

    Choose one answer.

    Show the answer to question 1

    Answer: The transition is skipped and ready rejects, but update still changes the DOM

    A name pairs one element in the old state with one in the new, so it must be unique among rendered elements. With a duplicate, all three engines rejected ready with an InvalidStateError, and the update still ran: a view transition is an enhancement that never stops the change itself.

  2. Question 2 of 6 How long, in milliseconds, does the default cross-fade of a view transition last if no stylesheet changes it?

    Type a number.

    Show the answer to question 2

    Answer: 250 ms

    The browser's own stylesheet gives every ::view-transition-group() an animation-duration of 0.25s, and the image pair and the images inherit it. The tab switcher's root group ran 250 ms in all three engines while the named groups used the durations the page set.

  3. Question 3 of 6 A badge has a view-transition-name only in the new state; it did not exist before. Which default animations run for it?

    Choose every answer that is right.

    Show the answer to question 3

    Answer:

    • ::view-transition-new(badge) fading in

    With nothing to move from, the group has no animation, and there is no old image. The new image fades in, which is the single animation all three engines reported for an arriving element. The blend is only added when an old and a new image overlap.

  4. Question 4 of 6 A 300 ms view transition is running, and the user clicks a button that is on screen. What does the click reach?

    Choose one answer.

    Show the answer to question 4

    Answer: The root element, not the button

    While the animation runs, captured elements, including the root, do not respond to hit-testing, so in all three engines a click in the middle of the transition hit the html element. Short transitions keep this from being noticed.

  5. Question 5 of 6 Both pages of a site have @view-transition { navigation: auto; }. Which of these navigations plays a transition in a browser that supports it?

    Choose one answer.

    Show the answer to question 5

    Answer: Clicking a link from one page to the other on the same site

    navigation: auto covers same-origin navigations that start from the page, such as links, forms and back or forward. Reloads, the address bar, bookmarks and navigations through a redirect to another origin are left out. Firefox plays none of them yet; it navigates normally.

  6. Question 6 of 6 A transition has the root and four named elements, all present before and after the change. How many default animations start?

    Type a number.

    Show the answer to question 6

    Answer: 25

    Each name present in both states gets five: the group's move and resize, the old image's fade-out and blend, and the new image's fade-in and blend. Five names make 25. The product card in the lesson had three names and started 15 in every engine.

Interview questions

Fresher · What does view-transition-name do, and why must it be unique?

view-transition-name marks an element to be captured on its own in a view transition, instead of as part of the page’s root snapshot. If an element with the same name exists before and after the change, the browser animates it from its old size and position to its new ones while its old image fades out and its new image fades in; if the name exists on only one side, that element simply fades out or in. Names are how the browser pairs the old element with the new one, so each name may belong to only one rendered element at a time. With a duplicate, the transition is skipped: ready rejects with an InvalidStateError, and the DOM still changes without animation. For many similar elements, use match-element to generate unique names, and view-transition-class to style them together.

Key takeaways

  • document.startViewTransition(update) snapshots the page, runs update, snapshots again and animates between them; the default is a 250 ms cross-fade of the root.
  • Each view-transition-name gets a group that moves and resizes between states, with the old image fading out and the new one fading in: five animations per name in all three engines.
  • Names must be unique among rendered elements, or the transition is skipped, though update still runs; use match-element and view-transition-class for lists.
  • Restyle with ::view-transition-group(), -old() and -new(); keep transitions short, because clicks go to the root while they run.
  • Remove large motion under prefers-reduced-motion: reduce.
  • @view-transition { navigation: auto; } on both pages animates same-origin navigations in Chromium and Safari; Firefox navigates normally.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.