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.
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 steps of a same-document view transition
Text description of the diagram
The diagram shows six steps in a column.
- A script calls document.startViewTransition(update).
- The browser captures the old state: a snapshot of the root and of every element with a view-transition-name.
- Rendering pauses while update() changes the DOM. When it has finished, the updateCallbackDone promise resolves.
- 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.
- The animations run: groups move and resize, old images fade out and new ones fade in. Meanwhile, clicks go to the root element.
- 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:
<!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:
/* 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 */
}
} <!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-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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
<!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-elementgives 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-classputs 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 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;
}
} <!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.
/* 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; }
}
} <!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:
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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, runsupdate, snapshots again and animates between them; the default is a 250 ms cross-fade of the root.- Each
view-transition-namegets 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
updatestill runs; usematch-elementandview-transition-classfor 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
- CSS View Transitions Module Level 1 (W3C CSS Working Group)
- CSS View Transitions Module Level 1: the user agent style sheet (W3C CSS Working Group)
- CSS View Transitions Module Level 2 (W3C CSS Working Group)
- Media Queries Level 5: prefers-reduced-motion (W3C CSS Working Group)
- View Transition API (MDN Web Docs) (Mozilla)
- view-transition-name (MDN Web Docs) (Mozilla)
- @view-transition (MDN Web Docs) (Mozilla)
- Web features explorer: view transitions (W3C WebDX Community Group)
- Web features explorer: cross-document view transitions (W3C WebDX Community Group)
- Web features explorer: view-transition-class (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress