SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/view-transitions

7 CSS View Transitions

0 Pure CSS7 with JSPublished 2026-10-05Free · no sign-up

Seven same-page view transitions: a list that animates when sorted, a tab indicator that glides between tabs, tags that reflow smoothly as they are added and removed, a filtered grid, a theme change revealed as a circle, a thumbnail that grows into a lightbox and a number that slides when it changes.

01. Animated List Reordering

View TransitionsList

Sorting a list animates every item from its old position to its new one. Each item gets a unique view-transition-name, the DOM is reordered inside startViewTransition, and the browser works out the movement; without support the list just reorders.

View code
<div class="vt1"><div class="bar"><button data-k="name">Sort by name</button><button data-k="price">Sort by price</button></div><ul><li style="view-transition-name:vt1a" data-name="Candle" data-price="18">Candle <b>$18</b></li><li style="view-transition-name:vt1b" data-name="Apron" data-price="32">Apron <b>$32</b></li><li style="view-transition-name:vt1c" data-name="Mug" data-price="14">Mug <b>$14</b></li><li style="view-transition-name:vt1d" data-name="Basket" data-price="26">Basket <b>$26</b></li></ul></div>
.vt1 { width: 250px; max-width: 100%; font: 14px Inter, system-ui, sans-serif; }
.vt1 .bar { display: flex; gap: 6px; margin-bottom: 10px; }
.vt1 button { flex: 1; padding: 8px; border: 1px solid #d0d5dd; border-radius: 8px; background: #fff; font: 600 12px Inter, system-ui, sans-serif; cursor: pointer; }
.vt1 button:focus-visible { outline: 3px solid #84adff; outline-offset: 2px; }
.vt1 ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.vt1 li { display: flex; justify-content: space-between; padding: 12px; border-radius: 10px; background: #f2f4f7; color: #101828; font-weight: 600; }
.vt1 b { color: #475467; }
::view-transition-group(*) { animation-duration: .4s; }
@media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }
var ul = document.querySelector('.vt1 ul');
document.querySelectorAll('.vt1 button').forEach(function (b) { b.onclick = function () {
  var k = b.dataset.k, items = Array.prototype.slice.call(ul.children).sort(function (x, y) { return k === 'price' ? x.dataset.price - y.dataset.price : x.dataset.name.localeCompare(y.dataset.name); });
  var go = function () { items.forEach(function (li) { ul.appendChild(li); }); };
  if (document.startViewTransition) document.startViewTransition(go); else go();
}; });

02. Tab Indicator That Glides

View TransitionsTabs

The underline of the active tab glides to the newly chosen tab because the indicator element is moved in the DOM inside a view transition and keeps the same view-transition-name. The tabs themselves follow the ARIA tabs pattern.

View code
<div class="vt2"><div role="tablist" aria-label="Report period"><button role="tab" aria-selected="true">Day<span class="ind"></span></button><button role="tab" aria-selected="false" tabindex="-1">Week</button><button role="tab" aria-selected="false" tabindex="-1">Month</button></div></div>
.vt2 [role=tablist] { display: flex; gap: 4px; padding: 4px; border-radius: 12px; background: #151b2e; }
.vt2 [role=tab] { position: relative; padding: 10px 16px; border: 0; border-radius: 8px; background: none; color: #94a3b8; font: 600 14px Inter, system-ui, sans-serif; cursor: pointer; }
.vt2 [aria-selected=true] { color: #fff; }
.vt2 .ind { position: absolute; left: 10px; right: 10px; bottom: 4px; height: 3px; border-radius: 2px; background: #38bdf8; view-transition-name: vt2ind; }
.vt2 [role=tab]:focus-visible { outline: 2px solid #38bdf8; outline-offset: 2px; }
::view-transition-group(vt2ind) { animation-duration: .3s; }
@media (prefers-reduced-motion: reduce) { ::view-transition-group(*) { animation: none !important; } }
var tabs = document.querySelectorAll('.vt2 [role=tab]'), ind = document.querySelector('.vt2 .ind');
function sel(t) { var go = function () { tabs.forEach(function (x) { var on = x === t; x.setAttribute('aria-selected', on); x.tabIndex = on ? 0 : -1; }); t.appendChild(ind); }; if (document.startViewTransition) document.startViewTransition(go); else go(); t.focus(); }
tabs.forEach(function (t, i) { t.onclick = function () { sel(t); }; t.onkeydown = function (e) { if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); sel(tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]); } }; });

03. Add and Remove with Smooth Reflow

View TransitionsList

Adding or removing a tag makes the others slide into their new places instead of jumping. Each tag gets a fresh unique view-transition-name when it is created, and new tags fade in through the default cross-fade of their ::view-transition-new.

View code
<div class="vt3"><form><label for="vt3i">Add a tag</label><div class="row"><input id="vt3i" value="summer"><button>Add</button></div></form><ul aria-label="Tags"><li style="view-transition-name:vt3t1">linen <button aria-label="Remove linen">&times;</button></li><li style="view-transition-name:vt3t2">organic <button aria-label="Remove organic">&times;</button></li></ul></div>
.vt3 { width: 280px; max-width: 100%; font: 14px Inter, system-ui, sans-serif; }
.vt3 label { display: block; margin-bottom: 4px; color: #101828; font-weight: 700; }
.vt3 .row { display: flex; gap: 6px; margin-bottom: 12px; }
.vt3 input { flex: 1; min-width: 0; padding: 9px 10px; border: 1px solid #98a2b3; border-radius: 8px; font: inherit; }
.vt3 form button { padding: 9px 14px; border: 0; border-radius: 8px; background: #101828; color: #fff; font: 700 13px Inter, system-ui, sans-serif; cursor: pointer; }
.vt3 ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
.vt3 li { display: inline-flex; align-items: center; gap: 4px; padding: 5px 6px 5px 12px; border-radius: 999px; background: #eef4ff; color: #1e3a8a; font-weight: 600; }
.vt3 li button { width: 22px; height: 22px; border: 0; border-radius: 50%; background: transparent; color: #1e3a8a; font-size: 16px; cursor: pointer; }
.vt3 :focus-visible { outline: 3px solid #84adff; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }
var ul = document.querySelector('.vt3 ul'), inp = document.getElementById('vt3i'), n = 3;
function vt(fn) { if (document.startViewTransition) document.startViewTransition(fn); else fn(); }
document.querySelector('.vt3 form').onsubmit = function (e) { e.preventDefault(); var v = inp.value.trim(); if (!v) return; vt(function () { var li = document.createElement('li'); li.style.viewTransitionName = 'vt3t' + (n++); li.textContent = v + ' '; var b = document.createElement('button'); b.setAttribute('aria-label', 'Remove ' + v); b.innerHTML = '&times;'; li.appendChild(b); ul.appendChild(li); }); inp.value = ''; inp.focus(); };
ul.onclick = function (e) { var b = e.target.closest('button'); if (!b) return; vt(function () { b.parentNode.remove(); }); inp.focus(); };

04. Filtering a Grid

View TransitionsFilter

Choosing a filter hides the cards that do not match, and the remaining cards glide into the gaps. The filter buttons are a radio-style group with aria-pressed, hidden cards use the hidden attribute, and the transition is one call around the update.

View code
<div class="vt4"><div class="f" role="group" aria-label="Filter"><button aria-pressed="true" data-f="all">All</button><button aria-pressed="false" data-f="cup">Cups</button><button aria-pressed="false" data-f="plate">Plates</button></div><ul><li data-t="cup" style="view-transition-name:vt4a">Cup</li><li data-t="plate" style="view-transition-name:vt4b">Plate</li><li data-t="cup" style="view-transition-name:vt4c">Cup</li><li data-t="plate" style="view-transition-name:vt4d">Plate</li><li data-t="cup" style="view-transition-name:vt4e">Cup</li><li data-t="plate" style="view-transition-name:vt4f">Plate</li></ul></div>
.vt4 { width: 270px; max-width: 100%; font: 14px Inter, system-ui, sans-serif; }
.vt4 .f { display: flex; gap: 6px; margin-bottom: 10px; }
.vt4 .f button { padding: 7px 12px; border: 1px solid #3a4670; border-radius: 999px; background: transparent; color: #cbd5e1; font: 600 13px Inter, system-ui, sans-serif; cursor: pointer; }
.vt4 .f [aria-pressed=true] { background: #e2e8f0; color: #0f172a; }
.vt4 .f button:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
.vt4 ul { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 0; padding: 0; list-style: none; }
.vt4 li { display: grid; place-items: center; height: 64px; border-radius: 12px; font-weight: 700; }
.vt4 [data-t=cup] { background: #0e7490; color: #ecfeff; } .vt4 [data-t=plate] { background: #a16207; color: #fefce8; }
@media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }
var bs = document.querySelectorAll('.vt4 .f button'), lis = document.querySelectorAll('.vt4 li');
bs.forEach(function (b) { b.onclick = function () { var f = b.dataset.f; var go = function () { bs.forEach(function (x) { x.setAttribute('aria-pressed', x === b); }); lis.forEach(function (li) { li.hidden = f !== 'all' && li.dataset.t !== f; }); }; if (document.startViewTransition) document.startViewTransition(go); else go(); }; });

05. Theme Switch with Circle Reveal

View TransitionsTheme

The new theme expands as a circle from the toggle, by animating clip-path on ::view-transition-new(vt5), the snapshot of the panel in its new state. The old and new snapshots are separate layers, so the circle reveals the real new state, text and all.

View code
<div class="vt5"><p>Reading list</p><button role="switch" aria-checked="false">Dark mode</button></div>
.vt5 { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 260px; max-width: 100%; padding: 22px; box-sizing: border-box; border: 1px solid #e4e7ec; border-radius: 16px; background: #fff; color: #101828; font: 700 16px Inter, system-ui, sans-serif; view-transition-name: vt5; }
.vt5.dark { background: #0f172a; border-color: #334155; color: #f8fafc; }
.vt5 p { margin: 0; }
.vt5 button { padding: 8px 12px; border: 1px solid currentColor; border-radius: 8px; background: transparent; color: inherit; font: 600 13px Inter, system-ui, sans-serif; cursor: pointer; }
.vt5 button:focus-visible { outline: 3px solid #84adff; outline-offset: 2px; }
::view-transition-old(vt5) { animation: none; }
::view-transition-new(vt5) { animation: vt5 .6s ease-in-out; }
@keyframes vt5 { from { clip-path: circle(0 at 85% 50%); } to { clip-path: circle(150% at 85% 50%); } }
@media (prefers-reduced-motion: reduce) { ::view-transition-new(vt5) { animation: none; } }
var w = document.querySelector('.vt5'), b = w.querySelector('button');
b.onclick = function () { var go = function () { var on = w.classList.toggle('dark'); b.setAttribute('aria-checked', on); }; if (document.startViewTransition) document.startViewTransition(go); else go(); };

06. Thumbnail to Lightbox

View TransitionsGallery

Opening a thumbnail morphs it into a large view and closing morphs it back, because the thumbnail and the large image share a view-transition-name for the length of the transition. The large view is a dialog, so focus and Escape are handled natively.

View code
<div class="vt6"><div class="th"><button style="--g:linear-gradient(135deg,#fb7185,#7c3aed)" aria-label="Open Dusk"></button><button style="--g:linear-gradient(135deg,#34d399,#0e7490)" aria-label="Open Lagoon"></button><button style="--g:linear-gradient(135deg,#fbbf24,#c2410c)" aria-label="Open Ember"></button></div><dialog aria-label="Photo"><div class="big"></div><button class="x">Close</button></dialog></div>
.vt6 .th { display: flex; gap: 8px; }
.vt6 .th button { width: 80px; height: 80px; border: 0; border-radius: 12px; background: var(--g); cursor: pointer; }
.vt6 button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.vt6 dialog { width: min(300px, 90vw); padding: 12px; border: 0; border-radius: 16px; background: #0f172a; }
.vt6 dialog::backdrop { background: rgba(0,0,0,.7); }
.vt6 .big { aspect-ratio: 1; border-radius: 12px; }
.vt6 .x { margin-top: 10px; padding: 8px 14px; border: 0; border-radius: 8px; background: #f8fafc; color: #0f172a; font: 700 13px Inter, system-ui, sans-serif; cursor: pointer; }
@media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }
var d = document.querySelector('.vt6 dialog'), big = d.querySelector('.big'), last;
function vt(fn) { if (document.startViewTransition) return document.startViewTransition(fn).finished; fn(); return Promise.resolve(); }
document.querySelectorAll('.vt6 .th button').forEach(function (t) { t.onclick = function () {
  last = t; t.style.viewTransitionName = 'vt6img';
  vt(function () { t.style.viewTransitionName = ''; big.style.background = t.style.getPropertyValue('--g'); big.style.viewTransitionName = 'vt6img'; d.showModal(); }).then(function () { big.style.viewTransitionName = ''; });
}; });
d.querySelector('.x').onclick = function () { big.style.viewTransitionName = 'vt6img'; vt(function () { big.style.viewTransitionName = ''; d.close(); last.style.viewTransitionName = 'vt6img'; }).then(function () { last.style.viewTransitionName = ''; }); };

07. Number Change with Slide

View TransitionsCustom animation

Changing a quantity slides the old number up and out while the new one slides up from below, by customising ::view-transition-old and ::view-transition-new for one named element. It shows that view transitions are not limited to cross-fades.

View code
<div class="vt7"><button aria-label="Decrease">−</button><output aria-live="polite">3</output><button aria-label="Increase">+</button></div>
.vt7 { display: inline-flex; align-items: center; gap: 4px; padding: 4px; border: 1px solid #d0d5dd; border-radius: 12px; background: #fff; font: 800 22px Inter, system-ui, sans-serif; }
.vt7 button { width: 40px; height: 40px; border: 0; border-radius: 8px; background: #f2f4f7; color: #101828; font-size: 20px; cursor: pointer; }
.vt7 button:focus-visible { outline: 3px solid #84adff; outline-offset: 1px; }
.vt7 output { min-width: 48px; text-align: center; color: #101828; view-transition-name: vt7n; }
::view-transition-group(vt7n) { overflow: clip; }
::view-transition-old(vt7n) { animation: vt7o .25s ease-in both; }
::view-transition-new(vt7n) { animation: vt7i .25s ease-out both; }
@keyframes vt7o { to { transform: translateY(-100%); opacity: 0; } }
@keyframes vt7i { from { transform: translateY(100%); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { ::view-transition-old(vt7n), ::view-transition-new(vt7n) { animation: none; } }
var o = document.querySelector('.vt7 output'), bs = document.querySelectorAll('.vt7 button');
function set(d) { var go = function () { o.value = Math.max(0, +o.value + d); }; if (document.startViewTransition) document.startViewTransition(go); else go(); }
bs[0].onclick = function () { set(-1); }; bs[1].onclick = function () { set(1); };

About these CSS View Transitions

How it works

document.startViewTransition(update) captures a picture of the page, runs your update, captures the new state and animates between them. Elements with a view-transition-name are animated individually from their old box to their new one.

Names must be unique

Each view-transition-name may appear only once on the page at a time. Give list items their own names, and move a shared name from one element to another during the update to morph between them.

Style the pseudo-elements

The animation lives on ::view-transition-group(), ::view-transition-old() and ::view-transition-new(). Override their animations to slide, clip or scale instead of the default cross-fade.

Progressive by design

Check for document.startViewTransition and call the update directly when it is missing. The page works the same; it just does not animate.

Frequently asked questions

Which browsers support view transitions?

Same-document transitions work in Chromium browsers, Safari 18 and Firefox 144 or later.

Do view transitions work between pages?

Yes, with @view-transition { navigation: auto; } for same-origin navigations in supporting browsers.

What about reduced motion?

Each demo turns the transition animations off under prefers-reduced-motion; the updates still happen.