GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

View Transitions API: 9 Patterns by One Value

View transitions api: The page seems to carry over, but the browser keeps the outgoing view as a snapshot value layered under the next -- paired by a name

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine panels below fetch, load, or truly navigate to a new document — item 02's cross-document flow is mocked entirely inside this one page, with a fake address-bar line and two swapped panels standing in for two real documents, and nothing here calls Math.random or reaches for a library. Swap the filler content for something real and the transition driver underneath doesn't need to change at all.

Why the support-check pattern (07) is worth reading before the other eight

Every item from 01 through 09 except 07 calls the same small change(fn) helper, and that helper itself checks 'startViewTransition' in document before deciding whether to call document.startViewTransition(fn) or just run fn() straight through. Item 07 doesn't hide that check inside a helper — it makes the check the entire point of the demo. Read 07 first and the other eight stop looking like nine unrelated tricks and start looking like one driver fed a different value each time: a name, a click point, a direction flag, a class, an order, a motion flag.

01Transition That Pairs Elements by a Unique Name Value

A card and a detail panel don't share markup, size, or position, but assigning both the same view-transition-name value at the moment one opens tells the browser to interpolate between their two snapshots as if one were smoothly growing into the other. Only the currently open card ever carries that name — handing it to all three cards at once collides the names and breaks the pairing.

Transition That Pairs Elements by a Unique Name Value
Open in a new tab

01 · Transition That Pairs Elements by a Unique Name Value — Measured area 44.292% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

view-transition-name::view-transition-group(np-shared)$duration
  // calc layer: the name value (view-transition-name) is sent only to the one card that's selected at that moment —
  // handing it to every card at once collides the names and breaks the pairing. The value is recomputed fresh each time.
  function assignName(id, on) {
    cards[id].style.viewTransitionName = on ? 'np-shared' : '';
  }

  function openRender(id) {
    if (opened !== null) assignName(opened, false);
    assignName(id, true);
    detail.style.viewTransitionName = 'np-shared';
    grid.hidden = true;
    detail.hidden = false;
    detailBadge.textContent = DATA[id].b;
    detailTitle.textContent = DATA[id].t;
    nameEl.textContent = 'card-' + id;
    opened = id;
  }

  function closeRender() {
    detail.hidden = true;
    grid.hidden = false;
    if (opened !== null) assignName(opened, false);
    detail.style.viewTransitionName = '';
    opened = null;
  }

02Cross-Document Transition That Survives a Real Navigation

This demo fakes a real cross-document navigation entirely inside one page — the address-bar text and the two panels below stand in for two separate documents, and nothing here actually reloads or fetches anything. A real multi-page site would opt into this same swap with @view-transition { navigation: auto }, and that view transition name auto behavior is what lets the browser pair up matching elements across two entirely different documents the same way change() pairs them by hand here with document.startViewTransition.

Cross-Document Transition That Survives a Real Navigation
Open in a new tab

02 · Cross-Document Transition That Survives a Real Navigation — Measured area 11.061% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

view-transition-name::view-transition-group(root)startViewTransition
  function toListRender() {
    urlEl.textContent = 'godrichstory.example/list';
    article.hidden = true;
    list.hidden = false;
    atArticle = false;
  }

  function change(fn) {
    if (SUPPORTS_VT) { document.startViewTransition(fn); }
    else { fn(); }
  }

  function toArticle() {
    if (atArticle) return;
    change(toArticleRender);
  }
  function toList() {
    if (!atArticle) return;
    change(toListRender);
  }

  openBtn.addEventListener('click', function () { human(); toArticle(); });
  backBtn.addEventListener('click', function () { human(); toList(); });

03Transition That Blooms in a Circle From the Clicked-Point Value

farthestCornerRadius measures the distance from the clicked coordinate to all four corners of the panel and keeps the largest one, so a circle grown to exactly that radius covers the whole panel the instant it finishes. playClip starts the transition, waits on t.ready, then animates clip-path on the new pseudo-element from a near-zero starting radius out to that computed one.

Transition That Blooms in a Circle From the Clicked-Point Value
Open in a new tab

03 · Transition That Blooms in a Circle From the Clicked-Point Value — Measured area 29.044% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

farthestCornerRadius::view-transition-new(cr-panel)CLIP_START_RADIUS_PX
  function playClip(x, y, rect) {
    if (!SUPPORTS_VT || !panel.animate) { renderState(); return; }
    var radius = farthestCornerRadius(x, y, rect);
    panel.style.viewTransitionName = 'cr-panel';
    var t = document.startViewTransition(function () { renderState(); });
    t.ready.then(function () {
      document.documentElement.animate(
        { clipPath: ['circle(' + CLIP_START_RADIUS_PX + 'px at ' + x + 'px ' + y + 'px)', 'circle(' + radius.toFixed(1) + 'px at ' + x + 'px ' + y + 'px)'] },
        { duration: 520, easing: 'ease-in', pseudoElement: '::view-transition-new(cr-panel)' }
      );
    });
    t.finished.then(function () { panel.style.viewTransitionName = ''; });
  }

  function toggleAt(x, y) {
    dark = !dark;
    var rect = panel.getBoundingClientRect();
    var lx = x - rect.left;
    var ly = y - rect.top;
    playClip(lx, ly, rect);
  }

04Nested Transition That Moves Parent and Child as One Group

The outer card carries its own name value, and its thumbnail and title each carry their own too — the CSS below groups all three under one ::view-transition-group timing so the children scale along with the frame instead of arriving at their final size half a beat late.

Nested Transition That Moves Parent and Child as One Group
Open in a new tab

04 · Nested Transition That Moves Parent and Child as One Group — Measured area 44.171% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

::view-transition-group(gn-card)::view-transition-group(gn-thumb)::view-transition-group(gn-title)
  function render() {
    card.classList.toggle('is-open', open);
  }

  function change(fn) {
    if (SUPPORTS_VT) { document.startViewTransition(fn); }
    else { fn(); }
  }

  function toggle() {
    open = !open;
    change(render);
  }

  card.addEventListener('click', function () { human(); toggle(); });
  root.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' || e.key === ' ') { human(); e.preventDefault(); toggle(); }
  });

05Transition That Assigns a Different Easing Value per Class

The hero block and the button each carry a different view-transition-class value — vt-slow on one, vt-fast on the other — and a separate ::view-transition-group(.class) rule for each class sets its own duration and easing, so the same click makes the background drift slowly while the button snaps fast.

Transition That Assigns a Different Easing Value per Class
Open in a new tab

05 · Transition That Assigns a Different Easing Value per Class — Measured area 29.267% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

view-transition-class::view-transition-group(.vt-slow)::view-transition-group(.vt-fast)
// live principle: give each element a different view-transition-class value (vt-slow / vt-fast), and
// even though each keeps its own name, ::view-transition-group(.class) lets you split duration by group --
// background slow (larger easing value), button fast (smaller easing value), same trigger, two speeds.
::view-transition-group(.vt-slow) {
  animation-duration: $duration / 2.2;
  animation-timing-function: $easing;
}
::view-transition-group(.vt-fast) {
  animation-duration: $dur-quick;
  animation-timing-function: $ease-pop;
}

06Slide Transition That Flips by Reading a Direction Value

goto takes only the sign of newIdx - idx as a direction value (+1 forward, -1 back), writes it into a --dir custom property before the transition starts, and the same slide keyframes read that property to decide which side the incoming step enters from — one set of keyframes, two directions.

Slide Transition That Flips by Reading a Direction Value
Open in a new tab

06 · Slide Transition That Flips by Reading a Direction Value — Measured area 31.792% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

--dir::view-transition-old(ds-step)$slide-travel-pct
  function render(newIdx) {
    numEl.textContent = String(newIdx + 1);
    tEl.textContent = STEPS[newIdx];
    dots.forEach(function (d, i) { d.classList.toggle('is-active', i === newIdx); });
    idx = newIdx;
  }

  function change(fn) {
    if (SUPPORTS_VT) { document.startViewTransition(fn); }
    else { fn(); }
  }

  function goto(newIdx) {
    if (newIdx === idx || newIdx < 0 || newIdx > 3) return;
    // calc layer: take only the sign of next index minus current index as the direction value (+1 forward, -1 back)
    var dir = Math.sign(newIdx - idx) || 1;
    document.documentElement.style.setProperty('--dir', String(dir));
    change(function () { render(newIdx); });
  }

07Transition That Falls Back After Checking a Support Value First

step() reads SUPPORTS_VT && !simulateUnsupported into one boolean before touching anything else — true runs document.startViewTransition(applyState), false calls applyState() directly with no animation at all, so an environment without support still gets a correct, instant state change instead of a stuck or blank screen.

Transition That Falls Back After Checking a Support Value First
Open in a new tab

07 · Transition That Falls Back After Checking a Support Value First — Measured area 9.112% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

startViewTransition::view-transition-old(sf-card)SUPPORTS_VT
  function applyState() {
    si = (si + 1) % STATES.length;
    numEl.textContent = STATES[si];
    card.classList.toggle('is-alt', si % 2 === 1);
  }

  // this function is the whole rule: check the support value first (SUPPORTS_VT), and when the simulation
  // stands in for "an environment without support," fall back to the instant path regardless of the real value.
  function step() {
    var willUseTransition = SUPPORTS_VT && !simulateUnsupported;
    if (willUseTransition) {
      document.startViewTransition(applyState);
    } else {
      applyState();
    }
    renderLabels(willUseTransition);
  }

08Transition Where Cards Slide as a Sort-Order Value Changes

Every card gets a stable lr-item-<id> name value once, up front, so re-sorting never has to reassign names — render() just re-sorts the array and calls appendChild in the new order, and it's the matching name values that let the browser slide each card from its old spot to its new one instead of cutting straight to the new layout.

Transition Where Cards Slide as a Sort-Order Value Changes
Open in a new tab

08 · Transition Where Cards Slide as a Sort-Order Value Changes — Measured area 26.685% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

viewTransitionName::view-transition-group(*)appendChild
  // calc layer: build a unique name value for each card from its data id -- even after the order changes,
  // the same card keeps the same name, so the browser can tell "this card moved to that spot."
  items.forEach(function (el) {
    el.style.viewTransitionName = 'lr-item-' + el.dataset.id;
  });

  var asc = true;
  var isDemo = true;

  function human() {
    if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
  }

  function render() {
    var sorted = items.slice().sort(function (a, b) {
      var av = Number(a.querySelector('.lr__price').dataset.v);
      var bv = Number(b.querySelector('.lr__price').dataset.v);
      return asc ? av - bv : bv - av;
    });
    sorted.forEach(function (el) { list.appendChild(el); });
    sortBtn.textContent = asc ? 'Price ascending' : 'Price descending';
  }

09Safeguard That Detects a Reduced-Motion Value and Turns the Transition Off

effectiveReduce() ORs the real system value together with a demo-only simulated value into one effective boolean, and step() only calls startViewTransition when that boolean is false and support exists — otherwise the duration display drops to zero and render() runs directly, so none of the eight transitions above ever plays for anyone whose system asked for reduced motion.

Safeguard That Detects a Reduced-Motion Value and Turns the Transition Off
Open in a new tab

09 · Safeguard That Detects a Reduced-Motion Value and Turns the Transition Off — Measured area 8.36% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

effectiveReduce::view-transition-new(rg-card)SUPPORTS_VT
  // calc layer: combine the real reduced-motion value with the demo's simulated value into one effective value, and
  // that value decides whether the duration drops to zero or keeps its normal value -- the accessibility decision always includes REAL_REDUCE.
  function effectiveReduce() { return REAL_REDUCE || simulateReduce; }

  function step() {
    var reduceNow = effectiveReduce();
    var dur = reduceNow ? 0 : NORMAL_MS;
    reduceEl.textContent = reduceNow ? 'Reduced motion on' : 'Reduced motion off';
    durVEl.textContent = String(dur);

    if (SUPPORTS_VT && !reduceNow) {
      document.startViewTransition(render);
    } else {
      render();
    }
  }

What the nine transitions measure out to, frame by frame

Item What computes the swap Moved area Motion intensity Frames moved (of 23)
01 name-paired snapshot interpolation 44.3% 59.7 7/23
02 mocked cross-document snapshot swap 11.1% 35.8 10/23
03 clip-path radius grown from a click point 29.0% 167.8 12/23
04 grouped parent-plus-child snapshot move 44.2% 116.4 12/23
05 per-class duration and easing split 29.3% 122.8 14/23
06 direction-flagged slide keyframes 31.8% 90.5 6/23
07 support-checked fallback branch 9.1% 93.2 9/23
08 order-value-driven card reflow 26.7% 59.4 12/23
09 reduced-motion-gated duration 8.4% 150.7 6/23

Two numbers stand out on purpose, not by accident: 07 and 09 both sit near the bottom of the moved-area column, because part of what each one measures is the branch where the transition barely moves at all — 07 while support is being simulated away, 09 while reduced motion is on.

Three constants you can push, and what shifts when you do

Variant Item Constant Base → variant What changes
01 Name-paired swap panel $duration 600ms → 300ms tightens the window where both snapshots overlap, so the swap reads as an abrupt cut rather than a gradual seep
03 Circle-reveal panel CLIP_START_RADIUS_PX 0 → 48 starts the circle already wide, reading less like a point-to-corner spread and more like a fast overlay swap
06 Direction-slide panel $slide-travel-pct 100% → 40% leaves the incoming step still overlapping the outgoing one instead of entering fully from outside

Where a reduced-motion value overrides every transition above

Items 01 through 06 and 08 each read the system's prefers-reduced-motion value once into a reduce flag; item 09 folds that same real value into effectiveReduce() alongside its own simulated one. Underneath every item, one shared rule does the actual work: ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } inside @media (prefers-reduced-motion: reduce) strips the pseudo-element animation entirely, so the state change lands instantly with zero moved frames — the same instant path 07 takes when support itself is missing.

Where this breaks — z-index, mid-flight interrupts, and elements that never got a name

Give two elements the same view-transition-name value at once and the browser can only pair one snapshot per name — that's exactly why item 01's assignName strips the name off the previously opened card before handing it to the next one; skip that step and the pairing collides. Fire a second startViewTransition before the first one's t.finished promise has resolved and the first transition is cancelled outright, with no animation on either side. And a view-transition-name set on an element the browser never painted a box for — a display: none element, say — does nothing at all, because there was no snapshot to pair in the first place. Reproducing any of that takes the real source, not this excerpt: the zip below carries every one of these nine index.html files plus their three variants, locked behind the password va4qdsmz, and it's worth typing that password in by hand rather than pasting it from a page that prints the plain answer right next to it.

Where a name-paired transition actually earns its place in a real screen

A product grid opening into a detail view is the clearest real fit for 01's paired-name trick — the thumbnail the user clicked keeps being the same element, visually, all the way into the detail layout, instead of vanishing and being replaced. 04 answers the complaint that shows up right after: once the card is that big, its thumbnail and title need to keep their relative position inside it too, not just arrive at the right final size a half-frame behind the frame that contains them.

06's direction flag and 08's order value both fit anywhere state already has an obvious "which way" or "which position" built into it — a wizard's step counter, a sort dropdown, a kanban column. Wire either one into a real data source and the only change from the demo above is where idx or the sort comparator gets its numbers from; the transition driver underneath doesn't move.

How this differs from a canvas reveal and a tab-panel css swap

A canvas reveal computes every visible pixel by hand — a getImageData readback, a painter's-algorithm loop, redrawn frame by frame onto a 2D canvas. None of these nine patterns work that way: the actual pixels are handed to the browser's own snapshot mechanism, and ::view-transition-old(name) / ::view-transition-new(name) are pseudo-elements the browser paints for you. Every view transitions api css rule in this set lives in one of those pseudo-elements; the JS above only ever sets a value — a name, a clip radius, a direction flag — for CSS or the Web Animations API to read.

A tab-panel set built on plain CSS transition does its interpolating on a property that already lives on the real element — height, transform, clip-path — right where it sits in the layout. This set never touches transition on the real elements at all. It always routes through the parallel pseudo-element tree the browser builds only while document.startViewTransition is mid-flight, which is also why none of these nine transitions show up if you inspect the real DOM mid-swap — the moving thing isn't there.

Common questions worth asking about these nine view transition patterns

Does a view transitions api demo need any external library to work?

No — every demo above runs on plain DOM calls, document.startViewTransition, and a handful of CSS pseudo-elements. There's no animation library, no CDN script, and no framework anywhere in the nine files.

Why does the frames-moved count land so far under the measured total for some of these?

Items 07 and 09 move the fewest frames on purpose — 07's whole point is the branch where no transition runs at all, and 09 spends part of its measured window at zero duration by design, not by accident in the demo.

What happens if two elements are given the same name value by accident?

The browser can only pair one snapshot per name at a time — item 01's assignName function exists specifically to strip the name off the previously opened card before handing it to the next one, and skipping that step is exactly the failure the trap section above walks through.

Where can you find more view transitions api examples like these?

Searching around for view transitions api examples usually turns up a handful of single-purpose demos scattered across different sites. What's collected above is nine, all driven by the same handful of shared values — a name, a support flag, a motion flag — instead of nine unrelated tricks, and the zip packages every rule and every JS driver so the source can be read directly instead of reverse-engineered out of a rendered page.

Sources: MDN — View Transitions API · MDN — view-transition-name · MDN — @view-transition · MDN — Document: startViewTransition() method · MDN — prefers-reduced-motion

Related reading: 9 CSS Dark Mode Toggle Animations, One Line Each · CSS Image Gallery Animation 9 One Line, Copy-Paste · Image Reveal Effect CSS — 9 Canvas-computed Wipes — the first also grows a circle out from a clicked point the same way pattern 03 above does, just through a plain CSS clip-path transition instead of a view-transition pseudo-element; the second computes per-tile reveal timing the same way pattern 08 above computes sort order, just for a hover-triggered gallery instead of a click-triggered list; the third is the canvas-computed comparison above — read it for the getImageData version of a wipe instead of the browser-snapshot version.

Enter the archive password

The password is inside this article. You will find it as you read.