The View Transition API animates the change between two states of a page. The browser takes a snapshot of the old state, you update the DOM, and the browser animates from the snapshot to the new state. Elements that share a view-transition-name morph from their old position and size to their new ones, and everything else cross-fades.

That removes most of the work that used to need a FLIP animation library: measuring boxes, inverting transforms and cleaning up afterwards. Same-document transitions (document.startViewTransition) work in Chrome, Edge, Firefox and Safari. Cross-document transitions between two pages of the same site need only one line of CSS and work in Chrome, Edge and Safari.

The eight demos below cover a filtered and sorted list, a theme switch with a circular reveal, tabs with a sliding indicator and directional transition types, a thumbnail that grows into a detail view, a grid-to-list layout switch, per-element timing with view-transition-class, a two-page cross-document transition and a reduced-motion pattern. Every demo still works, without animation, in a browser that lacks the API.

How a view transition works

Call document.startViewTransition() with a function that updates the DOM. The browser captures the current page, runs your function, captures the result, and then builds a tree of pseudo-elements over the page: ::view-transition holds one ::view-transition-group(name) per named element, and each group holds a ::view-transition-image-pair with ::view-transition-old (a screenshot of the old state) and ::view-transition-new (a live view of the new state).

By default the whole page is captured as one group called root, which cross-fades. Give an element view-transition-name: card-1 and it gets its own group: the group animates its width, height and position from the old box to the new one while the old and new images cross-fade inside it.

Every name must be unique among the rendered elements at the moment of capture. If two visible elements share a name, the browser skips the animation, still applies your DOM update and rejects the transition's ready promise. Elements with display: none are not captured, so a hidden element can safely carry the same name as a visible one.

The object returned by startViewTransition() has three promises – updateCallbackDone, ready (the pseudo-elements exist and the animation is about to start) and finished – plus skipTransition(). Use ready when you want to drive the animation from JavaScript, as the theme toggle below does.

Filter and sort a list so items morph into place

Each talk card has its own name set inline (view-transition-name: talk-1 and so on). Filtering hides cards with the hidden attribute and sorting re-appends them in a new order; both updates run inside startViewTransition(), so the cards that stay slide to their new grid cells.

A card that is filtered out exists only in the old state, and a card that comes back exists only in the new state. The selectors ::view-transition-old(*):only-child and ::view-transition-new(*):only-child target exactly those cases, so leaving and arriving cards can scale and fade while the rest move.

The small withTransition() helper is the pattern used in every demo: if the API is missing or the user prefers reduced motion, it runs the update directly. The page reaches the same state either way.

A screen reader gets the result through a polite live region (for example, 3 talks shown), because the visual motion carries no information on its own.

Open the demo in a new tab
Show the code
HTML
<h1>Frontend Day 2026 — schedule</h1>
<p class="lede">Filter by track or sort the talks. Each card morphs to its new place.</p>
<div class="toolbar" role="group" aria-label="Filter talks by track">
  <button class="chip" type="button" data-filter="all" aria-pressed="true">All</button>
  <button class="chip" type="button" data-filter="design" aria-pressed="false">Design</button>
  <button class="chip" type="button" data-filter="engineering" aria-pressed="false">Engineering</button>
  <button class="chip" type="button" data-filter="product" aria-pressed="false">Product</button>
  <button class="chip sort" type="button" id="sort" aria-pressed="false">Sort A–Z</button>
</div>
<p class="sr-only" id="status" aria-live="polite"></p>
<ul class="talks" id="talks">
  <li class="talk" data-track="engineering" style="view-transition-name: talk-1"><time>09:30</time><h2>Shipping CSS without a build step</h2><span class="track engineering">Engineering</span></li>
  <li class="talk" data-track="design" style="view-transition-name: talk-2"><time>10:15</time><h2>Type scales that survive real content</h2><span class="track design">Design</span></li>
  <li class="talk" data-track="product" style="view-transition-name: talk-3"><time>11:00</time><h2>Roadmaps your support team can read</h2><span class="track product">Product</span></li>
  <li class="talk" data-track="design" style="view-transition-name: talk-4"><time>11:45</time><h2>Colour contrast beyond the checker</h2><span class="track design">Design</span></li>
  <li class="talk" data-track="engineering" style="view-transition-name: talk-5"><time>13:30</time><h2>Measuring INP on low-end phones</h2><span class="track engineering">Engineering</span></li>
  <li class="talk" data-track="product" style="view-transition-name: talk-6"><time>14:15</time><h2>Pricing pages people trust</h2><span class="track product">Product</span></li>
  <li class="talk" data-track="engineering" style="view-transition-name: talk-7"><time>15:00</time><h2>Anchor positioning in production</h2><span class="track engineering">Engineering</span></li>
  <li class="talk" data-track="design" style="view-transition-name: talk-8"><time>15:45</time><h2>Motion that respects the user</h2><span class="track design">Design</span></li>
  <li class="talk" data-track="product" style="view-transition-name: talk-9"><time>16:30</time><h2>Onboarding without a product tour</h2><span class="track product">Product</span></li>
</ul>
CSS
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.chip {
  padding: 6px 14px; border: 1px solid #c5cad8; border-radius: 999px;
  background: #fff; color: #1c2333; font: inherit; font-size: 14px; cursor: pointer;
}
.chip[aria-pressed="true"] { background: #3346d3; border-color: #3346d3; color: #fff; }
.chip.sort { margin-left: auto; }

.talks { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; }
.talk { display: grid; grid-template-columns: 1fr auto; align-items: center; row-gap: 2px; padding: 8px 12px 10px; border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgb(28 35 51 / .08), 0 4px 12px rgb(28 35 51 / .06); }
.talk[hidden] { display: none; }
.talk time { font-size: 12px; font-weight: 600; color: #4a5468; }
.talk h2 { grid-column: 1 / -1; grid-row: 2; margin: 0; font-size: 14px; line-height: 1.3; }
.talk .track { grid-row: 1; justify-self: start; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.track.design { background: #fde7f0; color: #8a1846; }
.track.engineering { background: #e3f0ff; color: #0b4a8f; }
.track.product { background: #e6f6ea; color: #1d5e2c; }

/* Every item already has a unique view-transition-name (inline style),
   so the browser morphs each card from its old box to its new one. */
::view-transition-group(*) { animation-duration: 350ms; animation-timing-function: cubic-bezier(.2, .8, .2, 1); }

/* Cards leaving the list exist only in the old snapshot, cards arriving only
   in the new one. :only-child targets those cases. */
::view-transition-old(*):only-child { animation: 200ms ease-in both talk-out; }
::view-transition-new(*):only-child { animation: 250ms ease-out 100ms both talk-in; }
@keyframes talk-out { to { opacity: 0; scale: .85; } }
@keyframes talk-in { from { opacity: 0; scale: .85; } }
JavaScript
const list = document.getElementById('talks');
const status = document.getElementById('status');
const sortBtn = document.getElementById('sort');
const items = [...list.children];

// Run the DOM update inside a view transition when we can; otherwise just run it.
function withTransition(update) {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!document.startViewTransition || reduce) { update(); return; }
  document.startViewTransition(update);
}

document.querySelectorAll('[data-filter]').forEach((chip) => {
  chip.addEventListener('click', () => {
    withTransition(() => {
      document.querySelectorAll('[data-filter]').forEach((c) => c.setAttribute('aria-pressed', c === chip));
      const track = chip.dataset.filter;
      let shown = 0;
      items.forEach((li) => {
        li.hidden = track !== 'all' && li.dataset.track !== track;
        if (!li.hidden) shown++;
      });
      status.textContent = `${shown} talks shown`;
    });
  });
});

sortBtn.addEventListener('click', () => {
  withTransition(() => {
    const byTitle = sortBtn.getAttribute('aria-pressed') !== 'true';
    sortBtn.setAttribute('aria-pressed', byTitle);
    const key = (li) => byTitle ? li.querySelector('h2').textContent : li.querySelector('time').textContent;
    items.slice().sort((a, b) => key(a).localeCompare(key(b))).forEach((li) => list.append(li));
    status.textContent = byTitle ? 'Sorted by title' : 'Sorted by start time';
  });
});

Theme toggle with a circular reveal from the click point

Switching the theme changes the whole page, so this demo uses the root group. The CSS turns off the default cross-fade on ::view-transition-old(root) and ::view-transition-new(root), and sets mix-blend-mode: normal so the two snapshots do not blend.

When transition.ready resolves, the script animates clip-path on the new snapshot from a zero-radius circle to one large enough to cover the viewport. The pseudoElement option of element.animate() is what lets JavaScript target ::view-transition-new(root).

The circle starts at the pointer. For a keyboard click, event.detail is 0 and the pointer coordinates are meaningless, so the script uses the centre of the button instead. The button reports its state with aria-pressed.

Both themes are custom properties on :root, so the fallback is simply the same attribute change without the reveal.

Open the demo in a new tab
Show the code
HTML
<header class="bar">
  <strong>Field Notes</strong>
  <button class="theme-btn" type="button" id="theme" aria-pressed="false">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
    Dark theme
  </button>
</header>
<article class="article">
  <p class="kicker">Gardening · 4 min read</p>
  <h1>Why your tomatoes split after rain</h1>
  <p>Tomato skins stop stretching once the fruit starts to ripen. A heavy downpour after a dry week pushes water into the fruit faster than the skin can grow, and it cracks around the shoulders.</p>
  <p>Water deeply twice a week instead of a little every day, and mulch the bed so the soil dries out slowly.</p>
</article>
CSS
:root {
  --bg: #f6f4ef; --surface: #ffffff; --text: #22201c; --muted: #575249; --accent: #8a3b12; --border: #ddd6c8; --focus: #8a3b12;
  color-scheme: light;
}
:root[data-theme="dark"] {
  --bg: #15171c; --surface: #1f232b; --text: #ece9e2; --muted: #b3ada2; --accent: #f2a36b; --border: #353b47; --focus: #f2a36b;
  color-scheme: dark;
}
body { background: var(--bg); color: var(--text); }

.bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.bar strong { font-size: 17px; letter-spacing: .02em; }
.theme-btn {
  display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px;
  border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--text);
  font: inherit; font-size: 14px; cursor: pointer;
}
.theme-btn svg { width: 18px; height: 18px; }
.article { max-width: 60ch; padding: 18px 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); }
.article .kicker { margin: 0; color: var(--accent); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.article h1 { margin: 4px 0 8px; font-size: 22px; line-height: 1.25; }
.article p { margin: 0 0 10px; color: var(--muted); }

/* Turn off the default cross-fade: the new theme is revealed by a clip-path
   animation that the script runs on ::view-transition-new(root). */
::view-transition-old(root),
::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
JavaScript
const btn = document.getElementById('theme');

function setTheme() {
  const dark = document.documentElement.dataset.theme !== 'dark';
  document.documentElement.dataset.theme = dark ? 'dark' : 'light';
  btn.setAttribute('aria-pressed', dark);
}

btn.addEventListener('click', (event) => {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!document.startViewTransition || reduce) { setTheme(); return; }

  // Start the circle at the pointer, or at the button's centre for keyboard clicks.
  const box = btn.getBoundingClientRect();
  const x = event.detail ? event.clientX : box.left + box.width / 2;
  const y = event.detail ? event.clientY : box.top + box.height / 2;
  const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));

  const transition = document.startViewTransition(setTheme);
  transition.ready.then(() => {
    document.documentElement.animate(
      { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
      { duration: 550, easing: 'cubic-bezier(.4, 0, .2, 1)', pseudoElement: '::view-transition-new(root)' }
    );
  });
});

Tabs with a shared indicator and directional transition types

There is one indicator element with view-transition-name: tab-indicator. Selecting a tab moves that element into the new tab button, and because the name is the same before and after, the underline slides across instead of blinking.

The panel area is named tab-panel. Transition types decide how it animates: the script passes types: ['forwards'] or ['backwards'] to startViewTransition({ update, types }), and the CSS uses html:active-view-transition-type(forwards)::view-transition-old(tab-panel) to slide content in the direction of travel.

Types need Chrome 125, Safari 18.2 or Firefox 147. The script checks for 'types' in ViewTransition.prototype and falls back to the callback form, where the panel simply cross-fades. Browsers that do not understand :active-view-transition-type() drop those rules and use the default.

The tabs follow the ARIA tabs pattern: roving tabindex, arrow keys, Home and End, aria-selected and aria-controls.

Open the demo in a new tab
Show the code
HTML
<section class="trail" aria-labelledby="trail-title">
  <div class="trail-head">
    <h1 id="trail-title">Rocky Ridge Loop</h1>
    <p>11.4 km · 640 m ascent · 4–5 hours</p>
    <div role="tablist" aria-label="Trail details">
      <button role="tab" id="tab-overview" aria-controls="panel-overview" aria-selected="true">Overview<span class="indicator"></span></button>
      <button role="tab" id="tab-route" aria-controls="panel-route" aria-selected="false" tabindex="-1">Route</button>
      <button role="tab" id="tab-gear" aria-controls="panel-gear" aria-selected="false" tabindex="-1">Gear</button>
      <button role="tab" id="tab-permits" aria-controls="panel-permits" aria-selected="false" tabindex="-1">Permits</button>
    </div>
  </div>
  <div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" tabindex="0">
    <h2>A ridge walk with two summits</h2>
    <p>The loop climbs through beech forest to the open ridge, crosses Gray Knob and Hollin Peak, and drops back to the car park along the river.</p>
    <p>Best from May to October. The ridge is exposed, so turn back if storms are forecast.</p>
  </div>
  <div role="tabpanel" id="panel-route" aria-labelledby="tab-route" tabindex="0" hidden>
    <h2>Route, clockwise</h2>
    <ul>
      <li>Car park to Beech Hollow: 2.8 km, steady climb.</li>
      <li>Ridge to Gray Knob and Hollin Peak: 3.1 km, rocky in places.</li>
      <li>River path back to the start: 5.5 km, mostly flat.</li>
    </ul>
  </div>
  <div role="tabpanel" id="panel-gear" aria-labelledby="tab-gear" tabindex="0" hidden>
    <h2>What to bring</h2>
    <ul>
      <li>Boots with ankle support; the ridge has loose scree.</li>
      <li>Two litres of water. There is no refill point after the car park.</li>
      <li>A windproof layer, even in summer.</li>
    </ul>
  </div>
  <div role="tabpanel" id="panel-permits" aria-labelledby="tab-permits" tabindex="0" hidden>
    <h2>Permits and parking</h2>
    <p>No permit is needed for day hikes. The car park takes card payments only: €6 per day.</p>
    <p>Dogs must be on a lead from March to July for ground-nesting birds.</p>
  </div>
</section>
CSS
.trail { max-width: 640px; border-radius: 16px; background: #fff; box-shadow: 0 2px 10px rgb(27 35 48 / .08); overflow: hidden; }
.trail-head { padding: 16px 20px 0; }
.trail-head h1 { margin: 0; font-size: 19px; }
.trail-head p { margin: 2px 0 10px; color: #4b5563; font-size: 14px; }

[role="tablist"] { display: flex; gap: 4px; border-bottom: 1px solid #d7dee0; overflow-x: auto; }
[role="tab"] {
  position: relative; flex: none; padding: 10px 14px; border: 0; background: none;
  color: #4b5563; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer;
}
[role="tab"][aria-selected="true"] { color: #0d6b5e; }
@media (max-width: 420px) { [role="tab"] { padding-inline: 9px; } }
/* One indicator element, moved into the active tab. Its name makes it slide. */
.indicator {
  position: absolute; inset: auto 8px 0; height: 3px; border-radius: 3px 3px 0 0; background: #0d6b5e;
  view-transition-name: tab-indicator;
}

[role="tabpanel"] { padding: 16px 20px 20px; view-transition-name: tab-panel; }
[role="tabpanel"] h2 { margin: 0 0 6px; font-size: 16px; }
[role="tabpanel"] p, [role="tabpanel"] li { margin: 0 0 6px; color: #374151; font-size: 14px; }
[role="tabpanel"] ul { margin: 0; padding-left: 20px; }

/* Default: the panel cross-fades. With a transition type we slide it instead. */
::view-transition-group(tab-indicator) { animation-duration: 300ms; animation-timing-function: cubic-bezier(.3, .7, .2, 1); }
::view-transition-image-pair(tab-panel) { overflow: hidden; }
html:active-view-transition-type(forwards)::view-transition-old(tab-panel) { animation: 260ms ease-in both slide-to-left; }
html:active-view-transition-type(forwards)::view-transition-new(tab-panel) { animation: 260ms ease-out both slide-from-right; }
html:active-view-transition-type(backwards)::view-transition-old(tab-panel) { animation: 260ms ease-in both slide-to-right; }
html:active-view-transition-type(backwards)::view-transition-new(tab-panel) { animation: 260ms ease-out both slide-from-left; }
@keyframes slide-to-left { to { translate: -40px 0; opacity: 0; } }
@keyframes slide-from-right { from { translate: 40px 0; opacity: 0; } }
@keyframes slide-to-right { to { translate: 40px 0; opacity: 0; } }
@keyframes slide-from-left { from { translate: -40px 0; opacity: 0; } }
JavaScript
const tabs = [...document.querySelectorAll('[role="tab"]')];
const indicator = document.querySelector('.indicator');
const supportsTypes = typeof ViewTransition !== 'undefined' && 'types' in ViewTransition.prototype;

function selectTab(next) {
  const current = tabs.find((t) => t.getAttribute('aria-selected') === 'true');
  if (next === current) return;
  const update = () => {
    tabs.forEach((tab) => {
      const selected = tab === next;
      tab.setAttribute('aria-selected', selected);
      tab.tabIndex = selected ? 0 : -1;
      document.getElementById(tab.getAttribute('aria-controls')).hidden = !selected;
    });
    next.append(indicator);
  };
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!document.startViewTransition || reduce) { update(); return; }

  const direction = tabs.indexOf(next) > tabs.indexOf(current) ? 'forwards' : 'backwards';
  // Types need the object form (Chrome 125+, Safari 18.2+, Firefox 147+).
  if (supportsTypes) document.startViewTransition({ update, types: [direction] });
  else document.startViewTransition(update);
}

tabs.forEach((tab, i) => {
  tab.addEventListener('click', () => selectTab(tab));
  tab.addEventListener('keydown', (e) => {
    const keys = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 };
    if (!(e.key in keys)) return;
    e.preventDefault();
    const next = tabs[(keys[e.key] + tabs.length) % tabs.length];
    next.focus();
    selectTab(next);
  });
});

Thumbnail to detail view

This is the classic shared-element transition. The detail swatch and heading always carry view-transition-name: glaze-swatch and glaze-name in CSS, but only while the detail view is displayed. The grid thumbnails get the same names inline, and only on the one that was clicked.

On open, the script names the clicked thumbnail, then starts the transition. Inside the update it removes those names, fills in the detail view and hides the grid. The old snapshot has the names on the thumbnail and the new snapshot has them on the detail view, so each name is unique at each capture and the swatch grows into place.

Closing does the reverse and clears the inline names when transition.finished resolves. Focus moves to the detail heading on open and back to the same thumbnail on close, so keyboard users do not lose their place.

Keep the old and new boxes at the same aspect ratio where you can. When they differ, the snapshots are stretched or cropped during the morph, which you then have to correct with object-fit on the old and new pseudo-elements.

Open the demo in a new tab
Show the code
HTML
<h1>Studio glaze library</h1>
<p class="lede">Choose a glaze to see how it fires. The swatch and name grow into the detail view.</p>
<ul class="glazes" id="grid">
  <li data-glaze="celadon"><button class="thumb" type="button"><span class="swatch"></span><span class="name">Celadon</span></button></li>
  <li data-glaze="tenmoku"><button class="thumb" type="button"><span class="swatch"></span><span class="name">Tenmoku</span></button></li>
  <li data-glaze="shino"><button class="thumb" type="button"><span class="swatch"></span><span class="name">Shino</span></button></li>
  <li data-glaze="chun"><button class="thumb" type="button"><span class="swatch"></span><span class="name">Chun blue</span></button></li>
  <li data-glaze="copper"><button class="thumb" type="button"><span class="swatch"></span><span class="name">Copper red</span></button></li>
  <li data-glaze="ash"><button class="thumb" type="button"><span class="swatch"></span><span class="name">Wood ash</span></button></li>
</ul>
<section class="detail" id="detail" aria-labelledby="detail-name" hidden>
  <span class="swatch" id="detail-swatch"></span>
  <div>
    <h2 id="detail-name" tabindex="-1"></h2>
    <p class="meta" id="detail-meta"></p>
    <p id="detail-text"></p>
    <button class="back" type="button" id="back">Back to all glazes</button>
  </div>
</section>
CSS
.glazes { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; }
.thumb {
  display: block; width: 100%; padding: 0; border: 0; border-radius: 14px; background: #fff;
  color: inherit; font: inherit; text-align: left; cursor: pointer; overflow: hidden;
  box-shadow: 0 1px 3px rgb(36 32 29 / .12);
}
.swatch { display: block; aspect-ratio: 1; border-radius: 12px; }
.thumb .swatch { border-radius: 14px 14px 0 0; }
.thumb .name { display: block; padding: 8px 10px; font-weight: 600; font-size: 14px; }

.detail { display: grid; grid-template-columns: minmax(140px, 220px) 1fr; gap: 20px; align-items: start; }
.detail .swatch { view-transition-name: glaze-swatch; }
.detail h2 { margin: 0 0 4px; font-size: 22px; view-transition-name: glaze-name; width: fit-content; }
.detail .meta { margin: 0 0 8px; color: #5b524a; font-size: 14px; }
.detail p { margin: 0 0 12px; }
.glazes[hidden], .detail[hidden] { display: none; }
.back { padding: 8px 14px; border: 1px solid #b9ada0; border-radius: 999px; background: #fff; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }
@media (max-width: 480px) { .detail { grid-template-columns: 1fr; } .detail .swatch { max-width: 200px; } }

/* The swatch and the name morph between thumbnail and detail. */
::view-transition-group(glaze-swatch),
::view-transition-group(glaze-name) { animation-duration: 420ms; animation-timing-function: cubic-bezier(.2, .8, .2, 1); }

/* Glaze colours */
[data-glaze="celadon"] .swatch, .swatch.celadon { background: radial-gradient(circle at 30% 25%, #e7f3e4, #9cc2a5 55%, #6f9a7d); }
[data-glaze="tenmoku"] .swatch, .swatch.tenmoku { background: radial-gradient(circle at 30% 25%, #b8743f, #4a2412 55%, #1d0f08); }
[data-glaze="shino"] .swatch, .swatch.shino { background: radial-gradient(circle at 30% 25%, #fbf3ea, #e9c9a6 50%, #c98b5e); }
[data-glaze="chun"] .swatch, .swatch.chun { background: radial-gradient(circle at 30% 25%, #e9f0fb, #9db6db 55%, #6c86b8); }
[data-glaze="copper"] .swatch, .swatch.copper { background: radial-gradient(circle at 30% 25%, #f08a7a, #a3202a 55%, #5d0b14); }
[data-glaze="ash"] .swatch, .swatch.ash { background: radial-gradient(circle at 30% 25%, #d9d4a8, #8f8a52 55%, #514d27); }
JavaScript
const glazes = {
  celadon: ['Cone 10 reduction · gloss', 'A pale green from a trace of iron in reduction. It pools darker in carved lines, so it suits incised and fluted pots.'],
  tenmoku: ['Cone 10 reduction · gloss', 'A high-iron glaze that breaks to rust brown on rims and edges. Apply thickly; thin coats turn a flat amber.'],
  shino: ['Cone 10 reduction · satin', 'A feldspar-rich glaze that traps carbon and flashes orange where it is thin. Results vary a lot by kiln position.'],
  chun: ['Cone 10 reduction · opalescent', 'The milky blue comes from tiny droplets of silica-rich glass that scatter light. Slow cooling deepens it.'],
  copper: ['Cone 10 reduction · gloss', 'Copper reduces to a deep red. Too much oxygen during cooling turns it green, so keep the damper tight.'],
  ash: ['Cone 10 · variable', 'Made from sieved wood ash, clay and feldspar. It runs on vertical walls, so leave a wide foot.']
};
const grid = document.getElementById('grid');
const detail = document.getElementById('detail');
const swatch = document.getElementById('detail-swatch');
const heading = document.getElementById('detail-name');
let opener = null;

function withTransition(update) {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!document.startViewTransition || reduce) { update(); return Promise.resolve(); }
  return document.startViewTransition(update).finished;
}
// Names must be unique, so only the clicked thumbnail carries them, and only during the transition.
function name(button, on) {
  button.querySelector('.swatch').style.viewTransitionName = on ? 'glaze-swatch' : '';
  button.querySelector('.name').style.viewTransitionName = on ? 'glaze-name' : '';
}

grid.addEventListener('click', (e) => {
  const button = e.target.closest('.thumb');
  if (!button) return;
  const key = button.parentElement.dataset.glaze;
  opener = button;
  name(button, true);
  withTransition(() => {
    name(button, false);
    swatch.className = `swatch ${key}`;
    heading.textContent = button.textContent.trim();
    document.getElementById('detail-meta').textContent = glazes[key][0];
    document.getElementById('detail-text').textContent = glazes[key][1];
    grid.hidden = true;
    detail.hidden = false;
    heading.focus();
  });
});

document.getElementById('back').addEventListener('click', () => {
  withTransition(() => {
    detail.hidden = true;
    grid.hidden = false;
    name(opener, true);
    opener.focus();
  }).finally(() => name(opener, false));
});

Grid and list layout switch with match-element

Switching a data-view attribute changes the grid template, the avatar size and the text alignment. Each card, avatar, name and role moves independently because each has its own group.

Instead of writing six unique names by hand, the CSS uses view-transition-name: match-element, which gives every matched element an automatic name tied to the element itself. It needs Chrome 137, Safari 18.4 or Firefox 144; in older browsers that support view transitions the rule is ignored and the layout cross-fades as a whole.

view-transition-class: person lets one selector, ::view-transition-group(*.person), set the duration and easing for every one of those automatically named groups.

The name and role use width: fit-content so their boxes are the same size in both layouts. A group that only moves, rather than resizing, never stretches its text snapshot.

Open the demo in a new tab
Show the code
HTML
<div class="top">
  <h1>Support team</h1>
  <div class="switch" role="group" aria-label="Layout">
    <button type="button" data-view="grid" aria-pressed="true">Grid</button>
    <button type="button" data-view="list" aria-pressed="false">List</button>
  </div>
</div>
<ul class="people" id="people" data-view="grid">
  <li class="person"><span class="avatar" style="background:#4338ca" aria-hidden="true">AM</span><h2>Amara Mensah</h2><p class="role">Support lead</p></li>
  <li class="person"><span class="avatar" style="background:#0f766e" aria-hidden="true">JK</span><h2>Jonas Keller</h2><p class="role">Billing questions</p></li>
  <li class="person"><span class="avatar" style="background:#b4235a" aria-hidden="true">LS</span><h2>Lucía Serrano</h2><p class="role">Onboarding</p></li>
  <li class="person"><span class="avatar" style="background:#9a4a07" aria-hidden="true">TN</span><h2>Tomasz Nowak</h2><p class="role">API and webhooks</p></li>
  <li class="person"><span class="avatar" style="background:#1d4ed8" aria-hidden="true">PR</span><h2>Priya Raman</h2><p class="role">Accessibility audits</p></li>
  <li class="person"><span class="avatar" style="background:#6d28d9" aria-hidden="true">OB</span><h2>Owen Brady</h2><p class="role">Night shift, EMEA</p></li>
</ul>
CSS
.top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.top h1 { margin: 0; font-size: 18px; }
.switch { display: inline-flex; padding: 3px; border-radius: 999px; background: #dfe3ee; }
.switch button { padding: 5px 14px; border: 0; border-radius: 999px; background: none; color: #1d2230; font: inherit; font-size: 14px; cursor: pointer; }
.switch button[aria-pressed="true"] { background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / .15); font-weight: 600; }

.people { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin: 0; padding: 0; list-style: none; }
.person { display: grid; justify-items: center; gap: 2px; padding: 14px 10px; border-radius: 14px; background: #fff; text-align: center; box-shadow: 0 1px 3px rgb(29 34 48 / .1); }
.avatar { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; color: #fff; font-weight: 700; }
/* fit-content keeps each text box the same size in both layouts,
   so it only moves during the transition instead of stretching. */
.person h2, .person .role { width: fit-content; margin: 0; }
.person h2 { margin-top: 6px; font-size: 15px; }
.person .role { color: #4b5263; font-size: 13px; }

.people[data-view="list"] { grid-template-columns: 1fr; gap: 8px; }
.people[data-view="list"] .person { grid-template-columns: 40px 1fr; justify-items: start; column-gap: 12px; padding: 8px 12px; text-align: left; }
.people[data-view="list"] .avatar { grid-row: span 2; width: 40px; height: 40px; }
.people[data-view="list"] .person h2 { margin-top: 0; }

/* match-element gives each element its own automatic, unique name;
   view-transition-class lets one rule style all of them. */
.person, .person .avatar, .person h2, .person .role { view-transition-name: match-element; view-transition-class: person; }
::view-transition-group(*.person) { animation-duration: 400ms; animation-timing-function: cubic-bezier(.25, .8, .25, 1); }
JavaScript
const people = document.getElementById('people');
const buttons = document.querySelectorAll('.switch button');

buttons.forEach((button) => {
  button.addEventListener('click', () => {
    if (people.dataset.view === button.dataset.view) return;
    const update = () => {
      people.dataset.view = button.dataset.view;
      buttons.forEach((b) => b.setAttribute('aria-pressed', b === button));
    };
    const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (!document.startViewTransition || reduce) update();
    else document.startViewTransition(update);
  });
});

Per-element duration and easing with view-transition-class

Each task card has a unique name and the shared view-transition-class: task. The rule ::view-transition-group(*.task) reads its duration and easing from custom properties on :root, which the selects in the demo change. Custom properties inherit from the root into the transition pseudo-elements, so no script has to touch the animation itself.

The column counters use a second class, count, with a fast linear 150 ms animation and their own keyframes, so the numbers tick while the cards glide. The browser's default styles make ::view-transition-image-pair, ::view-transition-old and ::view-transition-new inherit the group's animation-duration, so setting it on the group is usually enough.

The overshoot option uses the linear() easing function, which approximates a spring with a list of points. Moving a card appends the same DOM element to another column; it keeps its name, so it morphs across columns.

view-transition-class needs Chrome 125, Safari 18.2 or Firefox 144. Where it is missing, the *.task selectors are invalid and dropped, and cards still move with the default timing.

Open the demo in a new tab
Show the code
HTML
<div class="board">
  <section class="column" aria-labelledby="col-todo">
    <h2 id="col-todo">To do <span class="count" style="view-transition-name: count-todo">2</span></h2>
    <ul class="tasks" data-column="todo">
      <li class="task" style="view-transition-name: task-1"><p>Write upgrade notes for the billing API</p><button type="button">Start</button></li>
      <li class="task" style="view-transition-name: task-2"><p>Translate the changelog into German</p><button type="button">Start</button></li>
    </ul>
  </section>
  <section class="column" aria-labelledby="col-doing">
    <h2 id="col-doing">Doing <span class="count" style="view-transition-name: count-doing">1</span></h2>
    <ul class="tasks" data-column="doing">
      <li class="task" style="view-transition-name: task-3"><p>Fix date picker focus trap in Safari</p><button type="button">Finish</button></li>
    </ul>
  </section>
  <section class="column" aria-labelledby="col-done">
    <h2 id="col-done">Done <span class="count" style="view-transition-name: count-done">1</span></h2>
    <ul class="tasks" data-column="done">
      <li class="task" style="view-transition-name: task-4"><p>Bump minimum Node version to 22</p><button type="button">Reopen</button></li>
    </ul>
  </section>
</div>
<p class="sr-only" id="status" aria-live="polite"></p>
CSS
:root { --task-duration: 450ms; --task-easing: cubic-bezier(.3, .7, .2, 1); }

.board { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.column { padding: 10px; border-radius: 12px; background: #e4e7ee; }
.column h2 { display: flex; justify-content: space-between; margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: #3c4353; }
.count { min-width: 22px; padding: 0 6px; border-radius: 999px; background: #fff; text-align: center; view-transition-class: count; }
.tasks { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.task { display: grid; gap: 6px; padding: 9px 10px; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px rgb(31 36 48 / .12); view-transition-class: task; }
.task p { margin: 0; }
.task button { justify-self: start; padding: 3px 10px; border: 1px solid #b6bccb; border-radius: 999px; background: #f7f8fb; color: #1f2430; font: inherit; font-size: 12px; cursor: pointer; }
@media (max-width: 560px) { .board { grid-template-columns: 1fr; } }

/* Every task card shares one class, so one rule times them all.
   The custom properties inherit from :root into the pseudo-elements. */
::view-transition-group(*.task) {
  animation-duration: var(--task-duration);
  animation-timing-function: var(--task-easing);
}
/* The counters change quickly and with no easing. */
::view-transition-group(*.count) { animation-duration: 150ms; animation-timing-function: linear; }
::view-transition-old(*.count) { animation: 150ms linear both count-out; }
::view-transition-new(*.count) { animation: 150ms linear both count-in; }
@keyframes count-out { to { opacity: 0; translate: 0 -6px; } }
@keyframes count-in { from { opacity: 0; translate: 0 6px; } }
JavaScript
const flow = { todo: ['doing', 'Finish'], doing: ['done', 'Reopen'], done: ['todo', 'Start'] };
const status = document.getElementById('status');

function withTransition(update) {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!document.startViewTransition || reduce) { update(); return; }
  document.startViewTransition(update);
}

document.querySelector('.board').addEventListener('click', (e) => {
  const button = e.target.closest('.task button');
  if (!button) return;
  const task = button.closest('.task');
  const [to, label] = flow[task.parentElement.dataset.column];
  withTransition(() => {
    document.querySelector(`[data-column="${to}"]`).append(task);
    button.textContent = label;
    document.querySelectorAll('.tasks').forEach((list) => {
      list.closest('.column').querySelector('.count').textContent = list.children.length;
    });
    button.focus();
    status.textContent = `Moved to ${document.querySelector(`[data-column="${to}"]`).closest('.column').querySelector('h2').firstChild.textContent.trim()}`;
  });
});

// Demo controls: change the custom properties the pseudo-elements read.
document.getElementById('duration').addEventListener('change', (e) => document.documentElement.style.setProperty('--task-duration', e.target.value));
document.getElementById('easing').addEventListener('change', (e) => document.documentElement.style.setProperty('--task-easing', e.target.value));

Cross-document transitions between two pages

Cross-document transitions need no JavaScript. Both pages include @view-transition { navigation: auto; }, and a same-origin navigation between them (a link click or the back and forward buttons) gets a cross-fade. Matching names on the two pages, here dish-shakshuka and title-shakshuka, make the recipe image and heading morph between the list and the detail page.

The demo has two files in one folder: index.html and detail.html. One detail page serves all three recipes, so a small script reads the ?r= parameter and sets the matching names before the page's first render, which is when the new state is captured.

Reloads and navigations typed into the address bar never get a transition, and Chrome skips the transition if the new page takes longer than four seconds to be ready. For finer control there are the pageswap and pagereveal events, which expose the transition object on the outgoing and incoming page.

The opt-in sits inside @media (prefers-reduced-motion: no-preference), so people who ask for less motion get ordinary page loads. In Firefox, which does not support @view-transition yet, the links are ordinary navigations. If nothing animates in the embedded preview, open the demo full-page.

Open the demo in a new tab
Show the code
HTML
<h1>Weeknight recipes</h1>
<ul class="recipes">
  <li><a class="recipe" href="detail.html?r=shakshuka"><span class="dish shakshuka" style="view-transition-name: dish-shakshuka"></span><h2 style="view-transition-name: title-shakshuka">Shakshuka</h2><p>25 min · serves 2</p></a></li>
  <li><a class="recipe" href="detail.html?r=dal"><span class="dish dal" style="view-transition-name: dish-dal"></span><h2 style="view-transition-name: title-dal">Red lentil dal</h2><p>35 min · serves 4</p></a></li>
  <li><a class="recipe" href="detail.html?r=focaccia"><span class="dish focaccia" style="view-transition-name: dish-focaccia"></span><h2 style="view-transition-name: title-focaccia">Overnight focaccia</h2><p>14 h, mostly waiting · serves 6</p></a></li>
</ul>
CSS
/* Both pages opt in. Only same-origin navigations get a transition. */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
}
::view-transition-group(*) { animation-duration: 380ms; animation-timing-function: cubic-bezier(.2, .8, .2, 1); }

.recipes { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; margin: 0; padding: 0; list-style: none; }
.recipe { display: block; border-radius: 16px; background: #fff; color: inherit; text-decoration: none; overflow: hidden; box-shadow: 0 1px 3px rgb(42 33 27 / .14); }
.recipe .dish { display: block; aspect-ratio: 4 / 3; }
.recipe h2 { margin: 0; padding: 10px 12px 2px; font-size: 16px; width: fit-content; }
.recipe p { margin: 0; padding: 0 12px 12px; color: #5c4e43; font-size: 13px; }

.dish.shakshuka { background: radial-gradient(circle at 35% 40%, #fff7e0 0 9%, #f6c443 10% 14%, transparent 15%), radial-gradient(circle at 65% 60%, #fff7e0 0 9%, #f6c443 10% 14%, transparent 15%), radial-gradient(circle, #c7361f 0 55%, #3a2a22 56%); }
.dish.dal { background: radial-gradient(circle at 50% 45%, #f4e3b0 0 6%, transparent 7%), radial-gradient(circle at 38% 36%, #3f7a2a 0 2.5%, transparent 3%), radial-gradient(circle at 60% 62%, #3f7a2a 0 2.5%, transparent 3%), radial-gradient(circle at 64% 38%, #3f7a2a 0 2.5%, transparent 3%), radial-gradient(circle, #e2a227 0 55%, #7a4a26 56%); }
.dish.focaccia { background: radial-gradient(circle at 30% 35%, #4f7a2b 0 4%, transparent 5%), radial-gradient(circle at 62% 58%, #4f7a2b 0 4%, transparent 5%), radial-gradient(circle at 78% 28%, #4f7a2b 0 4%, transparent 5%), radial-gradient(circle at 22% 72%, #4f7a2b 0 4%, transparent 5%), radial-gradient(circle, #b8792f 0 3px, transparent 4px) 0 0 / 28px 28px, linear-gradient(135deg, #ecc47a, #c98a3d); }

Respecting reduced motion

prefers-reduced-motion: reduce does not have to mean no feedback at all. This demo has three modes. Full motion slides the archived message away and lets the rest close the gap. Reduced sets animation: none on every ::view-transition-group(*), so nothing travels across the screen, and keeps a short cross-fade so the change is still visible. Off skips startViewTransition() entirely.

The script reads matchMedia('(prefers-reduced-motion: reduce)') and writes the effective mode to a data-motion attribute on the html element, which the CSS keys off. It also listens for changes, so switching the operating system setting takes effect without a reload. The radio buttons let you try each mode without changing your system settings.

The other demos on this page take the simplest route and skip the transition when reduced motion is requested. Either approach is reasonable; a fade without movement is often a good middle ground for state changes that are easy to miss.

Open the demo in a new tab
Show the code
HTML
<div class="inbox-head">
  <h1 id="inbox-title" tabindex="-1">Inbox</h1>
  <button class="restore" type="button" id="restore">Restore all</button>
</div>
<ul class="messages" id="messages">
  <li class="message" style="view-transition-name: msg-1"><strong>Dentist reminder</strong><span>Check-up on Thursday at 08:40</span><button type="button" aria-label="Archive: Dentist reminder">Archive</button></li>
  <li class="message" style="view-transition-name: msg-2"><strong>Invoice 2026-118 paid</strong><span>Lumen Studio paid 1,240.00 EUR</span><button type="button" aria-label="Archive: Invoice 2026-118 paid">Archive</button></li>
  <li class="message" style="view-transition-name: msg-3"><strong>Your parcel is out for delivery</strong><span>Expected between 13:00 and 15:00</span><button type="button" aria-label="Archive: Your parcel is out for delivery">Archive</button></li>
  <li class="message" style="view-transition-name: msg-4"><strong>Library loan due</strong><span>"The Overstory" is due back on 21 October</span><button type="button" aria-label="Archive: Library loan due">Archive</button></li>
</ul>
<p class="sr-only" id="status" aria-live="polite"></p>
CSS
.inbox-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.inbox-head h1 { margin: 0; font-size: 17px; }
.restore { padding: 4px 12px; border: 1px solid #b4bccb; border-radius: 999px; background: #fff; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }
.messages { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.message { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; align-items: center; padding: 8px 12px; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px rgb(30 36 49 / .1); }
.message strong { font-size: 14px; }
.message span { grid-column: 1; color: #4a5263; font-size: 13px; }
.message button { grid-row: 1 / span 2; grid-column: 2; padding: 4px 12px; border: 1px solid #b4bccb; border-radius: 999px; background: #f6f7fa; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }
.empty { margin: 0; padding: 16px; border-radius: 10px; background: #fff; color: #4a5263; text-align: center; }

/* Full motion: the archived message slides away and the rest close the gap. */
::view-transition-group(*) { animation-duration: 320ms; animation-timing-function: cubic-bezier(.3, .7, .2, 1); }
::view-transition-old(*):only-child { animation: 260ms ease-in both slide-away; }
@keyframes slide-away { to { opacity: 0; translate: 60px 0; } }

/* Reduced motion: nothing moves across the screen. Groups jump straight to
   their end position and the old and new snapshots only cross-fade. */
html[data-motion="reduced"]::view-transition-group(*) { animation: none; }
html[data-motion="reduced"]::view-transition-old(*):only-child { animation: 180ms linear both fade-away; }
@keyframes fade-away { to { opacity: 0; } }
JavaScript
const root = document.documentElement;
const reduceQuery = matchMedia('(prefers-reduced-motion: reduce)');
let setting = 'system';

// The effective mode: "full", "reduced" (cross-fade only) or "off" (no transition).
function motionMode() {
  return setting === 'system' ? (reduceQuery.matches ? 'reduced' : 'full') : setting;
}
function applyMode() { root.dataset.motion = motionMode(); }
reduceQuery.addEventListener('change', applyMode);
applyMode();

function withTransition(update) {
  if (!document.startViewTransition || motionMode() === 'off') { update(); return; }
  document.startViewTransition(update);
}

const list = document.getElementById('messages');
const original = [...list.children];
const status = document.getElementById('status');

list.addEventListener('click', (e) => {
  const button = e.target.closest('button');
  if (!button) return;
  const item = button.closest('li');
  const next = item.nextElementSibling || item.previousElementSibling;
  withTransition(() => {
    item.remove();
    status.textContent = `Archived ${item.querySelector('strong').textContent}`;
    (next ? next.querySelector('button') : document.getElementById('inbox-title')).focus();
  });
});

document.getElementById('restore').addEventListener('click', () => {
  withTransition(() => {
    list.replaceChildren(...original);
    status.textContent = 'All messages restored';
  });
});

Browser support, fallbacks and progressive enhancement

Same-document view transitions reached Baseline in October 2025 with Firefox 144, after Chrome and Edge 111 and Safari 18. Cross-document transitions are in Chrome and Edge 126 and Safari 18.2 but not in Firefox; they are a focus area of Interop 2026.

The API is a pure enhancement, so the fallback is the DOM update itself. Feature-detect with if (!document.startViewTransition) and call the update function directly. Check optional parts on their own: 'types' in ViewTransition.prototype for transition types, CSS.supports('view-transition-class', 'x') for classes, and 'CSSViewTransitionRule' in window for cross-document support.

In CSS, unsupported selectors such as *.task or :active-view-transition-type() invalidate only the rule they appear in, so older engines fall back to the default cross-fade. Each demo on this page shows a short note when the API is missing; to test the fallback in a supporting browser, delete Document.prototype.startViewTransition before the page runs.

Accessibility

Animation should never be the only signal that something changed. Use real state attributes (aria-pressed, aria-selected, hidden) and announce results in a live region where the change is not obvious.

Manage focus inside the update callback. When an element disappears or a view swaps, move focus somewhere sensible: the detail heading, the next item in the list, or back to the control that opened the view.

Respect prefers-reduced-motion either by skipping the transition or by replacing movement with a short fade, and keep durations under half a second for interface changes. The transition overlay sits above the page while it runs, so long animations make the interface feel slow to respond.

Frequently asked questions

Do CSS view transitions need JavaScript?

Same-document transitions do: you call document.startViewTransition() around the DOM change. Cross-document transitions between two pages of the same site need only CSS, @view-transition { navigation: auto; } on both pages, plus matching view-transition-name values for elements that should morph.

Which browsers support the View Transition API?

Same-document transitions work in Chrome and Edge 111, Safari 18 and Firefox 144. Cross-document transitions work in Chrome and Edge 126 and Safari 18.2; Firefox does not support them yet. Transition types arrived in Chrome 125, Safari 18.2 and Firefox 147.

Why does my view transition not animate?

The most common cause is two visible elements with the same view-transition-name, which makes the browser skip the animation and reject the ready promise. Other causes are a reduced-motion check in your own code, an element that is display: none at capture time, or, for cross-document transitions, a missing @view-transition rule on one of the two pages or a cross-origin link.

How do I give one element a different duration or easing?

Target its group by name, for example ::view-transition-group(hero) { animation-duration: 600ms; }. For many elements, add a shared view-transition-class and select ::view-transition-group(*.card). The old and new images inherit the group's duration by default.

Can I use view transitions with a framework?

Yes. Call document.startViewTransition() and make sure the DOM is updated by the time the callback returns or its promise resolves. In React that usually means wrapping the state change in flushSync inside the callback. Astro's ClientRouter component uses the API for client-side page navigation.

More from the Modern CSS lab