17 / 20
All 20 in 20 CSS View Transitions
- 01Basic SPA View Transition
- 02Cross Document MPA Transition
- 03Dynamic View Transition Name
- 04Custom View Transition Keyframes
- 05View Transition Types
- 06Card to Full Page Detail Morph
- 07Image Grid to Lightbox Transition
- 08List Reorder and Filtering
- 09Tab Switcher Indicator Movement
- 10Circular Clip Path Theme Toggle
- 11Add to Cart Badge Counter Morph
- 12Accordion Expand Smooth Height
- 13Off Canvas Sidebar Drawer
- 14Native Modal Dialog Open Close
- 15Multi Step Form Wizard
- 16Accessible Transitions Reduced Motion
- 17Feature Detection Graceful Fallback
- 18Fixing Aspect Ratio Image Distortion
- 19Nested View Transition Isolation
- 20Preventing Flicker and Z Index Stacking
Feature Detection Graceful Fallback
Firefox has not shipped this API. The one-line guard — if (!document.startViewTransition) { update(); return; } — is what separates a progressive enhancement from a broken page, and it has to run before any other transition code. This is the complete wrapper every other demo in the collection uses, with the un-transitioned path shown as the correct path rather than a degraded one.
Build interactive UI like this from scratch: 20 HTML, CSS & JavaScript projects
ZTM · JavaScript Web Projects · 30‑day money-back guarantee · Affiliate link
The code
<section class="vtr-17" aria-labelledby="vtr-17-heading">
<fieldset class="vtr-17__theme">
<legend class="vtr-17__themelegend">Theme</legend>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-auto" checked>
<label class="vtr-17__themeopt" for="vtr-17-theme-auto">Auto</label>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-light">
<label class="vtr-17__themeopt" for="vtr-17-theme-light">Light</label>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-dark">
<label class="vtr-17__themeopt" for="vtr-17-theme-dark">Dark</label>
</fieldset>
<div class="vtr-17__stage">
<header class="vtr-17__head">
<p class="vtr-17__kicker">progressive enhancement</p>
<h2 class="vtr-17__heading" id="vtr-17-heading">The guard clause, in full</h2>
</header>
<pre class="vtr-17__code"><code><span class="vtr-17__c">// the wrapper every demo in this collection uses</span>
<span class="vtr-17__k">let</span> running = <span class="vtr-17__v">false</span>;
<span class="vtr-17__k">async function</span> <span class="vtr-17__f">transition</span>(update, types) {
<span class="vtr-17__c">// 1 · no API, or the user asked for less motion</span>
<span class="vtr-17__k">if</span> (!document.startViewTransition ||
matchMedia(<span class="vtr-17__s">'(prefers-reduced-motion: reduce)'</span>).matches) {
update();
<span class="vtr-17__k">return</span> <span class="vtr-17__v">null</span>;
}
<span class="vtr-17__c">// 2 · never stack two transitions</span>
<span class="vtr-17__k">if</span> (running) <span class="vtr-17__k">return</span> <span class="vtr-17__v">null</span>;
running = <span class="vtr-17__v">true</span>;
<span class="vtr-17__k">const</span> t = document.<span class="vtr-17__f">startViewTransition</span>(
types ? { update, types } : update
);
<span class="vtr-17__k">try</span> { <span class="vtr-17__k">await</span> t.finished; } <span class="vtr-17__k">finally</span> { running = <span class="vtr-17__v">false</span>; }
<span class="vtr-17__k">return</span> t;
}</code></pre>
<div class="vtr-17__run">
<button class="vtr-17__btn" type="button" id="vtr-17-run">run transition(update)</button>
<div class="vtr-17__out" id="vtr-17-panel">
<p class="vtr-17__line"><span class="vtr-17__prompt">></span><span id="vtr-17-branch">awaiting call</span></p>
<p class="vtr-17__line vtr-17__line--dim">api: <span id="vtr-17-api">checking</span> · reduced-motion: <span id="vtr-17-rm">checking</span></p>
<p class="vtr-17__state" id="vtr-17-state">build 4118 · queued</p>
</div>
</div>
<ul class="vtr-17__notes">
<li>Firefox has not shipped the API. The DOM update still applies there, instantly.</li>
<li>Detect the feature, never the browser — the check stays correct when support changes.</li>
<li>The guard runs before any name assignment, so nothing is left attached.</li>
</ul>
</div>
</section><section class="vtr-17" aria-labelledby="vtr-17-heading">
<fieldset class="vtr-17__theme">
<legend class="vtr-17__themelegend">Theme</legend>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-auto" checked>
<label class="vtr-17__themeopt" for="vtr-17-theme-auto">Auto</label>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-light">
<label class="vtr-17__themeopt" for="vtr-17-theme-light">Light</label>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-dark">
<label class="vtr-17__themeopt" for="vtr-17-theme-dark">Dark</label>
</fieldset>
<div class="vtr-17__stage">
<header class="vtr-17__head">
<p class="vtr-17__kicker">progressive enhancement</p>
<h2 class="vtr-17__heading" id="vtr-17-heading">The guard clause, in full</h2>
</header>
<pre class="vtr-17__code"><code><span class="vtr-17__c">// the wrapper every demo in this collection uses</span>
<span class="vtr-17__k">let</span> running = <span class="vtr-17__v">false</span>;
<span class="vtr-17__k">async function</span> <span class="vtr-17__f">transition</span>(update, types) {
<span class="vtr-17__c">// 1 · no API, or the user asked for less motion</span>
<span class="vtr-17__k">if</span> (!document.startViewTransition ||
matchMedia(<span class="vtr-17__s">'(prefers-reduced-motion: reduce)'</span>).matches) {
update();
<span class="vtr-17__k">return</span> <span class="vtr-17__v">null</span>;
}
<span class="vtr-17__c">// 2 · never stack two transitions</span>
<span class="vtr-17__k">if</span> (running) <span class="vtr-17__k">return</span> <span class="vtr-17__v">null</span>;
running = <span class="vtr-17__v">true</span>;
<span class="vtr-17__k">const</span> t = document.<span class="vtr-17__f">startViewTransition</span>(
types ? { update, types } : update
);
<span class="vtr-17__k">try</span> { <span class="vtr-17__k">await</span> t.finished; } <span class="vtr-17__k">finally</span> { running = <span class="vtr-17__v">false</span>; }
<span class="vtr-17__k">return</span> t;
}</code></pre>
<div class="vtr-17__run">
<button class="vtr-17__btn" type="button" id="vtr-17-run">run transition(update)</button>
<div class="vtr-17__out" id="vtr-17-panel">
<p class="vtr-17__line"><span class="vtr-17__prompt">></span><span id="vtr-17-branch">awaiting call</span></p>
<p class="vtr-17__line vtr-17__line--dim">api: <span id="vtr-17-api">checking</span> · reduced-motion: <span id="vtr-17-rm">checking</span></p>
<p class="vtr-17__state" id="vtr-17-state">build 4118 · queued</p>
</div>
</div>
<ul class="vtr-17__notes">
<li>Firefox has not shipped the API. The DOM update still applies there, instantly.</li>
<li>Detect the feature, never the browser — the check stays correct when support changes.</li>
<li>The guard runs before any name assignment, so nothing is left attached.</li>
</ul>
</div>
</section>.vtr-17 {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
--font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
background-image: repeating-linear-gradient(to bottom, rgba(52, 224, 127, 0.045) 0 1px, transparent 1px 3px);
color: var(--ink);
font-family: var(--font-display);
color-scheme: dark light;
}
.vtr-17 *,
.vtr-17 *::before,
.vtr-17 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-17 {
--surface: oklch(16% 0.02 160);
--line: oklch(26% 0.035 160);
--ink: oklch(93% 0.06 160);
--muted: oklch(62% 0.07 160);
--accent: oklch(82% 0.19 150);
--accent-soft: oklch(24% 0.07 155);
}
}
@media (prefers-color-scheme: light) {
.vtr-17 {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
background-image: repeating-linear-gradient(to bottom, rgba(15, 122, 67, 0.04) 0 1px, transparent 1px 3px);
}
}
.vtr-17[data-theme="light"] {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
}
.vtr-17[data-theme="dark"] {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
}
.vtr-17__stage {
max-width: 52rem;
margin: 0 auto;
padding: clamp(1rem, 3.5vw, 2rem) clamp(0.75rem, 3.5vw, 1.75rem);
display: grid;
gap: 0.75rem;
}
.vtr-17__kicker {
margin: 0 0 0.2rem;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-17__heading {
margin: 0;
font-size: clamp(1.1rem, 3.6vw, 1.5rem);
font-weight: 600;
letter-spacing: -0.01em;
}
.vtr-17__code {
margin: 0;
padding: 0.75rem 0.85rem;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 0.25rem;
background: var(--surface);
font-size: 0.7rem;
line-height: 1.55;
}
.vtr-17__c {
color: var(--muted);
}
.vtr-17__k {
color: var(--accent);
}
.vtr-17__f {
color: var(--ink);
font-weight: 700;
}
.vtr-17__s {
color: #9be7bf;
}
.vtr-17__v {
color: #7ee8ae;
}
.vtr-17__run {
display: grid;
gap: 0.5rem;
}
.vtr-17__btn {
justify-self: start;
min-height: 44px;
padding: 0 0.9rem;
border: 1px solid var(--accent);
border-radius: 0.2rem;
background: var(--accent-soft);
color: var(--accent);
font-family: inherit;
font-size: 0.76rem;
cursor: pointer;
}
.vtr-17__btn:hover {
background: var(--accent);
color: var(--bg);
}
.vtr-17__btn:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.vtr-17__out {
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: 0.25rem;
background: var(--surface);
display: grid;
gap: 0.3rem;
min-inline-size: 0;
}
.vtr-17__line {
margin: 0;
font-size: 0.72rem;
word-break: break-word;
}
.vtr-17__line--dim {
color: var(--muted);
font-size: 0.68rem;
}
.vtr-17__prompt {
color: var(--accent);
margin-right: 0.4rem;
}
.vtr-17__state {
margin: 0;
padding: 0.5rem 0.6rem;
border-left: 2px solid var(--accent);
background: var(--accent-soft);
font-size: 0.8rem;
view-transition-name: vtr-17-state;
}
.vtr-17__notes {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
font-size: 0.7rem;
line-height: 1.5;
color: var(--muted);
}
::view-transition-group(vtr-17-state) {
animation-duration: 300ms;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-17-state) {
animation-duration: 1ms;
}
}
/* Theme switch (added 2026-08-24). Pure CSS: three radios + :has(). Auto
follows the OS and the site's [data-theme] stamp; Light and Dark pin the
palette. Values are the demo's own [data-theme] palettes reused verbatim,
so the toggle can never drift from the site-theme rendering. */
.vtr-17 {
position: relative;
}
.vtr-17__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.vtr-17__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-17__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-17__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.vtr-17__themeopt:hover {
opacity: 0.85;
}
.vtr-17__themeinput:checked + .vtr-17__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-17__themeinput:focus-visible + .vtr-17__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-17:has(#vtr-17-theme-light:checked) {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
}
.vtr-17:has(#vtr-17-theme-dark:checked) {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
}.vtr-17 {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
--font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
background-image: repeating-linear-gradient(to bottom, rgba(52, 224, 127, 0.045) 0 1px, transparent 1px 3px);
color: var(--ink);
font-family: var(--font-display);
color-scheme: dark light;
}
.vtr-17 *,
.vtr-17 *::before,
.vtr-17 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-17 {
--surface: oklch(16% 0.02 160);
--line: oklch(26% 0.035 160);
--ink: oklch(93% 0.06 160);
--muted: oklch(62% 0.07 160);
--accent: oklch(82% 0.19 150);
--accent-soft: oklch(24% 0.07 155);
}
}
@media (prefers-color-scheme: light) {
.vtr-17 {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
background-image: repeating-linear-gradient(to bottom, rgba(15, 122, 67, 0.04) 0 1px, transparent 1px 3px);
}
}
.vtr-17[data-theme="light"] {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
}
.vtr-17[data-theme="dark"] {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
}
.vtr-17__stage {
max-width: 52rem;
margin: 0 auto;
padding: clamp(1rem, 3.5vw, 2rem) clamp(0.75rem, 3.5vw, 1.75rem);
display: grid;
gap: 0.75rem;
}
.vtr-17__kicker {
margin: 0 0 0.2rem;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-17__heading {
margin: 0;
font-size: clamp(1.1rem, 3.6vw, 1.5rem);
font-weight: 600;
letter-spacing: -0.01em;
}
.vtr-17__code {
margin: 0;
padding: 0.75rem 0.85rem;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 0.25rem;
background: var(--surface);
font-size: 0.7rem;
line-height: 1.55;
}
.vtr-17__c {
color: var(--muted);
}
.vtr-17__k {
color: var(--accent);
}
.vtr-17__f {
color: var(--ink);
font-weight: 700;
}
.vtr-17__s {
color: #9be7bf;
}
.vtr-17__v {
color: #7ee8ae;
}
.vtr-17__run {
display: grid;
gap: 0.5rem;
}
.vtr-17__btn {
justify-self: start;
min-height: 44px;
padding: 0 0.9rem;
border: 1px solid var(--accent);
border-radius: 0.2rem;
background: var(--accent-soft);
color: var(--accent);
font-family: inherit;
font-size: 0.76rem;
cursor: pointer;
}
.vtr-17__btn:hover {
background: var(--accent);
color: var(--bg);
}
.vtr-17__btn:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.vtr-17__out {
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: 0.25rem;
background: var(--surface);
display: grid;
gap: 0.3rem;
min-inline-size: 0;
}
.vtr-17__line {
margin: 0;
font-size: 0.72rem;
word-break: break-word;
}
.vtr-17__line--dim {
color: var(--muted);
font-size: 0.68rem;
}
.vtr-17__prompt {
color: var(--accent);
margin-right: 0.4rem;
}
.vtr-17__state {
margin: 0;
padding: 0.5rem 0.6rem;
border-left: 2px solid var(--accent);
background: var(--accent-soft);
font-size: 0.8rem;
view-transition-name: vtr-17-state;
}
.vtr-17__notes {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
font-size: 0.7rem;
line-height: 1.5;
color: var(--muted);
}
::view-transition-group(vtr-17-state) {
animation-duration: 300ms;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-17-state) {
animation-duration: 1ms;
}
}
/* Theme switch (added 2026-08-24). Pure CSS: three radios + :has(). Auto
follows the OS and the site's [data-theme] stamp; Light and Dark pin the
palette. Values are the demo's own [data-theme] palettes reused verbatim,
so the toggle can never drift from the site-theme rendering. */
.vtr-17 {
position: relative;
}
.vtr-17__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.vtr-17__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-17__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-17__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.vtr-17__themeopt:hover {
opacity: 0.85;
}
.vtr-17__themeinput:checked + .vtr-17__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-17__themeinput:focus-visible + .vtr-17__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-17:has(#vtr-17-theme-light:checked) {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
}
.vtr-17:has(#vtr-17-theme-dark:checked) {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
}(() => {
const root = document.querySelector('.vtr-17');
if (!root || root.dataset.vtr17) return;
root.dataset.vtr17 = 'on';
const state = root.querySelector('#vtr-17-state');
const branch = root.querySelector('#vtr-17-branch');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const supported = typeof document.startViewTransition === 'function';
root.querySelector('#vtr-17-api').textContent = supported ? 'available' : 'undefined';
root.querySelector('#vtr-17-rm').textContent = reduce.matches ? 'reduce' : 'no-preference';
const builds = [['build 4118 · queued', 'build 4118 · running on orbit-2'], ['build 4118 · running on orbit-2', 'build 4118 · passed in 2m 14s'], ['build 4118 · passed in 2m 14s', 'build 4118 · queued']];
let i = 0, running = false;
const update = () => { state.textContent = builds[i][1]; i = (i + 1) % builds.length; };
root.querySelector('#vtr-17-run').addEventListener('click', async () => {
if (!supported || reduce.matches) { update(); branch.textContent = 'guard hit · updated instantly'; return; }
if (running) { branch.textContent = 'in flight · call ignored'; return; }
running = true; branch.textContent = 'transition running';
try { await document.startViewTransition(update).finished; branch.textContent = 'transition finished'; }
finally { running = false; }
});
})();(() => {
const root = document.querySelector('.vtr-17');
if (!root || root.dataset.vtr17) return;
root.dataset.vtr17 = 'on';
const state = root.querySelector('#vtr-17-state');
const branch = root.querySelector('#vtr-17-branch');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const supported = typeof document.startViewTransition === 'function';
root.querySelector('#vtr-17-api').textContent = supported ? 'available' : 'undefined';
root.querySelector('#vtr-17-rm').textContent = reduce.matches ? 'reduce' : 'no-preference';
const builds = [['build 4118 · queued', 'build 4118 · running on orbit-2'], ['build 4118 · running on orbit-2', 'build 4118 · passed in 2m 14s'], ['build 4118 · passed in 2m 14s', 'build 4118 · queued']];
let i = 0, running = false;
const update = () => { state.textContent = builds[i][1]; i = (i + 1) % builds.length; };
root.querySelector('#vtr-17-run').addEventListener('click', async () => {
if (!supported || reduce.matches) { update(); branch.textContent = 'guard hit · updated instantly'; return; }
if (running) { branch.textContent = 'in flight · call ignored'; return; }
running = true; branch.textContent = 'transition running';
try { await document.startViewTransition(update).finished; branch.textContent = 'transition finished'; }
finally { running = false; }
});
})();Here's a working CSS View Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Feature Detection Graceful Fallback
Source: https://codefronts.com/motion/css-view-transitions/feature-detection-graceful-fallback/
Firefox has not shipped this API. The one-line guard — if (!document.startViewTransition) { update(); return; } — is what separates a progressive enhancement from a broken page, and it has to run before any other transition code. This is the complete wrapper every other demo in the collection uses, with the un-transitioned path shown as the correct path rather than a degraded one.
## HTML
```html
<section class="vtr-17" aria-labelledby="vtr-17-heading">
<fieldset class="vtr-17__theme">
<legend class="vtr-17__themelegend">Theme</legend>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-auto" checked>
<label class="vtr-17__themeopt" for="vtr-17-theme-auto">Auto</label>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-light">
<label class="vtr-17__themeopt" for="vtr-17-theme-light">Light</label>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-dark">
<label class="vtr-17__themeopt" for="vtr-17-theme-dark">Dark</label>
</fieldset>
<div class="vtr-17__stage">
<header class="vtr-17__head">
<p class="vtr-17__kicker">progressive enhancement</p>
<h2 class="vtr-17__heading" id="vtr-17-heading">The guard clause, in full</h2>
</header>
<pre class="vtr-17__code"><code><span class="vtr-17__c">// the wrapper every demo in this collection uses</span>
<span class="vtr-17__k">let</span> running = <span class="vtr-17__v">false</span>;
<span class="vtr-17__k">async function</span> <span class="vtr-17__f">transition</span>(update, types) {
<span class="vtr-17__c">// 1 · no API, or the user asked for less motion</span>
<span class="vtr-17__k">if</span> (!document.startViewTransition ||
matchMedia(<span class="vtr-17__s">'(prefers-reduced-motion: reduce)'</span>).matches) {
update();
<span class="vtr-17__k">return</span> <span class="vtr-17__v">null</span>;
}
<span class="vtr-17__c">// 2 · never stack two transitions</span>
<span class="vtr-17__k">if</span> (running) <span class="vtr-17__k">return</span> <span class="vtr-17__v">null</span>;
running = <span class="vtr-17__v">true</span>;
<span class="vtr-17__k">const</span> t = document.<span class="vtr-17__f">startViewTransition</span>(
types ? { update, types } : update
);
<span class="vtr-17__k">try</span> { <span class="vtr-17__k">await</span> t.finished; } <span class="vtr-17__k">finally</span> { running = <span class="vtr-17__v">false</span>; }
<span class="vtr-17__k">return</span> t;
}</code></pre>
<div class="vtr-17__run">
<button class="vtr-17__btn" type="button" id="vtr-17-run">run transition(update)</button>
<div class="vtr-17__out" id="vtr-17-panel">
<p class="vtr-17__line"><span class="vtr-17__prompt">></span><span id="vtr-17-branch">awaiting call</span></p>
<p class="vtr-17__line vtr-17__line--dim">api: <span id="vtr-17-api">checking</span> · reduced-motion: <span id="vtr-17-rm">checking</span></p>
<p class="vtr-17__state" id="vtr-17-state">build 4118 · queued</p>
</div>
</div>
<ul class="vtr-17__notes">
<li>Firefox has not shipped the API. The DOM update still applies there, instantly.</li>
<li>Detect the feature, never the browser — the check stays correct when support changes.</li>
<li>The guard runs before any name assignment, so nothing is left attached.</li>
</ul>
</div>
</section>
```
## CSS
```css
.vtr-17 {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
--font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
background-image: repeating-linear-gradient(to bottom, rgba(52, 224, 127, 0.045) 0 1px, transparent 1px 3px);
color: var(--ink);
font-family: var(--font-display);
color-scheme: dark light;
}
.vtr-17 *,
.vtr-17 *::before,
.vtr-17 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-17 {
--surface: oklch(16% 0.02 160);
--line: oklch(26% 0.035 160);
--ink: oklch(93% 0.06 160);
--muted: oklch(62% 0.07 160);
--accent: oklch(82% 0.19 150);
--accent-soft: oklch(24% 0.07 155);
}
}
@media (prefers-color-scheme: light) {
.vtr-17 {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
background-image: repeating-linear-gradient(to bottom, rgba(15, 122, 67, 0.04) 0 1px, transparent 1px 3px);
}
}
.vtr-17[data-theme="light"] {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
}
.vtr-17[data-theme="dark"] {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
}
.vtr-17__stage {
max-width: 52rem;
margin: 0 auto;
padding: clamp(1rem, 3.5vw, 2rem) clamp(0.75rem, 3.5vw, 1.75rem);
display: grid;
gap: 0.75rem;
}
.vtr-17__kicker {
margin: 0 0 0.2rem;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-17__heading {
margin: 0;
font-size: clamp(1.1rem, 3.6vw, 1.5rem);
font-weight: 600;
letter-spacing: -0.01em;
}
.vtr-17__code {
margin: 0;
padding: 0.75rem 0.85rem;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 0.25rem;
background: var(--surface);
font-size: 0.7rem;
line-height: 1.55;
}
.vtr-17__c {
color: var(--muted);
}
.vtr-17__k {
color: var(--accent);
}
.vtr-17__f {
color: var(--ink);
font-weight: 700;
}
.vtr-17__s {
color: #9be7bf;
}
.vtr-17__v {
color: #7ee8ae;
}
.vtr-17__run {
display: grid;
gap: 0.5rem;
}
.vtr-17__btn {
justify-self: start;
min-height: 44px;
padding: 0 0.9rem;
border: 1px solid var(--accent);
border-radius: 0.2rem;
background: var(--accent-soft);
color: var(--accent);
font-family: inherit;
font-size: 0.76rem;
cursor: pointer;
}
.vtr-17__btn:hover {
background: var(--accent);
color: var(--bg);
}
.vtr-17__btn:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.vtr-17__out {
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: 0.25rem;
background: var(--surface);
display: grid;
gap: 0.3rem;
min-inline-size: 0;
}
.vtr-17__line {
margin: 0;
font-size: 0.72rem;
word-break: break-word;
}
.vtr-17__line--dim {
color: var(--muted);
font-size: 0.68rem;
}
.vtr-17__prompt {
color: var(--accent);
margin-right: 0.4rem;
}
.vtr-17__state {
margin: 0;
padding: 0.5rem 0.6rem;
border-left: 2px solid var(--accent);
background: var(--accent-soft);
font-size: 0.8rem;
view-transition-name: vtr-17-state;
}
.vtr-17__notes {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
font-size: 0.7rem;
line-height: 1.5;
color: var(--muted);
}
::view-transition-group(vtr-17-state) {
animation-duration: 300ms;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-17-state) {
animation-duration: 1ms;
}
}
/* Theme switch (added 2026-08-24). Pure CSS: three radios + :has(). Auto
follows the OS and the site's [data-theme] stamp; Light and Dark pin the
palette. Values are the demo's own [data-theme] palettes reused verbatim,
so the toggle can never drift from the site-theme rendering. */
.vtr-17 {
position: relative;
}
.vtr-17__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.vtr-17__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-17__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-17__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.vtr-17__themeopt:hover {
opacity: 0.85;
}
.vtr-17__themeinput:checked + .vtr-17__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-17__themeinput:focus-visible + .vtr-17__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-17:has(#vtr-17-theme-light:checked) {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
}
.vtr-17:has(#vtr-17-theme-dark:checked) {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.vtr-17');
if (!root || root.dataset.vtr17) return;
root.dataset.vtr17 = 'on';
const state = root.querySelector('#vtr-17-state');
const branch = root.querySelector('#vtr-17-branch');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const supported = typeof document.startViewTransition === 'function';
root.querySelector('#vtr-17-api').textContent = supported ? 'available' : 'undefined';
root.querySelector('#vtr-17-rm').textContent = reduce.matches ? 'reduce' : 'no-preference';
const builds = [['build 4118 · queued', 'build 4118 · running on orbit-2'], ['build 4118 · running on orbit-2', 'build 4118 · passed in 2m 14s'], ['build 4118 · passed in 2m 14s', 'build 4118 · queued']];
let i = 0, running = false;
const update = () => { state.textContent = builds[i][1]; i = (i + 1) % builds.length; };
root.querySelector('#vtr-17-run').addEventListener('click', async () => {
if (!supported || reduce.matches) { update(); branch.textContent = 'guard hit · updated instantly'; return; }
if (running) { branch.textContent = 'in flight · call ignored'; return; }
running = true; branch.textContent = 'transition running';
try { await document.startViewTransition(update).finished; branch.textContent = 'transition finished'; }
finally { running = false; }
});
})();
```Here's a working CSS View Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Feature Detection Graceful Fallback
Source: https://codefronts.com/motion/css-view-transitions/feature-detection-graceful-fallback/
Firefox has not shipped this API. The one-line guard — if (!document.startViewTransition) { update(); return; } — is what separates a progressive enhancement from a broken page, and it has to run before any other transition code. This is the complete wrapper every other demo in the collection uses, with the un-transitioned path shown as the correct path rather than a degraded one.
## HTML
```html
<section class="vtr-17" aria-labelledby="vtr-17-heading">
<fieldset class="vtr-17__theme">
<legend class="vtr-17__themelegend">Theme</legend>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-auto" checked>
<label class="vtr-17__themeopt" for="vtr-17-theme-auto">Auto</label>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-light">
<label class="vtr-17__themeopt" for="vtr-17-theme-light">Light</label>
<input class="vtr-17__themeinput" type="radio" name="vtr-17-theme" id="vtr-17-theme-dark">
<label class="vtr-17__themeopt" for="vtr-17-theme-dark">Dark</label>
</fieldset>
<div class="vtr-17__stage">
<header class="vtr-17__head">
<p class="vtr-17__kicker">progressive enhancement</p>
<h2 class="vtr-17__heading" id="vtr-17-heading">The guard clause, in full</h2>
</header>
<pre class="vtr-17__code"><code><span class="vtr-17__c">// the wrapper every demo in this collection uses</span>
<span class="vtr-17__k">let</span> running = <span class="vtr-17__v">false</span>;
<span class="vtr-17__k">async function</span> <span class="vtr-17__f">transition</span>(update, types) {
<span class="vtr-17__c">// 1 · no API, or the user asked for less motion</span>
<span class="vtr-17__k">if</span> (!document.startViewTransition ||
matchMedia(<span class="vtr-17__s">'(prefers-reduced-motion: reduce)'</span>).matches) {
update();
<span class="vtr-17__k">return</span> <span class="vtr-17__v">null</span>;
}
<span class="vtr-17__c">// 2 · never stack two transitions</span>
<span class="vtr-17__k">if</span> (running) <span class="vtr-17__k">return</span> <span class="vtr-17__v">null</span>;
running = <span class="vtr-17__v">true</span>;
<span class="vtr-17__k">const</span> t = document.<span class="vtr-17__f">startViewTransition</span>(
types ? { update, types } : update
);
<span class="vtr-17__k">try</span> { <span class="vtr-17__k">await</span> t.finished; } <span class="vtr-17__k">finally</span> { running = <span class="vtr-17__v">false</span>; }
<span class="vtr-17__k">return</span> t;
}</code></pre>
<div class="vtr-17__run">
<button class="vtr-17__btn" type="button" id="vtr-17-run">run transition(update)</button>
<div class="vtr-17__out" id="vtr-17-panel">
<p class="vtr-17__line"><span class="vtr-17__prompt">></span><span id="vtr-17-branch">awaiting call</span></p>
<p class="vtr-17__line vtr-17__line--dim">api: <span id="vtr-17-api">checking</span> · reduced-motion: <span id="vtr-17-rm">checking</span></p>
<p class="vtr-17__state" id="vtr-17-state">build 4118 · queued</p>
</div>
</div>
<ul class="vtr-17__notes">
<li>Firefox has not shipped the API. The DOM update still applies there, instantly.</li>
<li>Detect the feature, never the browser — the check stays correct when support changes.</li>
<li>The guard runs before any name assignment, so nothing is left attached.</li>
</ul>
</div>
</section>
```
## CSS
```css
.vtr-17 {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
--font-display: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
background-image: repeating-linear-gradient(to bottom, rgba(52, 224, 127, 0.045) 0 1px, transparent 1px 3px);
color: var(--ink);
font-family: var(--font-display);
color-scheme: dark light;
}
.vtr-17 *,
.vtr-17 *::before,
.vtr-17 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-17 {
--surface: oklch(16% 0.02 160);
--line: oklch(26% 0.035 160);
--ink: oklch(93% 0.06 160);
--muted: oklch(62% 0.07 160);
--accent: oklch(82% 0.19 150);
--accent-soft: oklch(24% 0.07 155);
}
}
@media (prefers-color-scheme: light) {
.vtr-17 {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
background-image: repeating-linear-gradient(to bottom, rgba(15, 122, 67, 0.04) 0 1px, transparent 1px 3px);
}
}
.vtr-17[data-theme="light"] {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
}
.vtr-17[data-theme="dark"] {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
}
.vtr-17__stage {
max-width: 52rem;
margin: 0 auto;
padding: clamp(1rem, 3.5vw, 2rem) clamp(0.75rem, 3.5vw, 1.75rem);
display: grid;
gap: 0.75rem;
}
.vtr-17__kicker {
margin: 0 0 0.2rem;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-17__heading {
margin: 0;
font-size: clamp(1.1rem, 3.6vw, 1.5rem);
font-weight: 600;
letter-spacing: -0.01em;
}
.vtr-17__code {
margin: 0;
padding: 0.75rem 0.85rem;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 0.25rem;
background: var(--surface);
font-size: 0.7rem;
line-height: 1.55;
}
.vtr-17__c {
color: var(--muted);
}
.vtr-17__k {
color: var(--accent);
}
.vtr-17__f {
color: var(--ink);
font-weight: 700;
}
.vtr-17__s {
color: #9be7bf;
}
.vtr-17__v {
color: #7ee8ae;
}
.vtr-17__run {
display: grid;
gap: 0.5rem;
}
.vtr-17__btn {
justify-self: start;
min-height: 44px;
padding: 0 0.9rem;
border: 1px solid var(--accent);
border-radius: 0.2rem;
background: var(--accent-soft);
color: var(--accent);
font-family: inherit;
font-size: 0.76rem;
cursor: pointer;
}
.vtr-17__btn:hover {
background: var(--accent);
color: var(--bg);
}
.vtr-17__btn:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.vtr-17__out {
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: 0.25rem;
background: var(--surface);
display: grid;
gap: 0.3rem;
min-inline-size: 0;
}
.vtr-17__line {
margin: 0;
font-size: 0.72rem;
word-break: break-word;
}
.vtr-17__line--dim {
color: var(--muted);
font-size: 0.68rem;
}
.vtr-17__prompt {
color: var(--accent);
margin-right: 0.4rem;
}
.vtr-17__state {
margin: 0;
padding: 0.5rem 0.6rem;
border-left: 2px solid var(--accent);
background: var(--accent-soft);
font-size: 0.8rem;
view-transition-name: vtr-17-state;
}
.vtr-17__notes {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
font-size: 0.7rem;
line-height: 1.5;
color: var(--muted);
}
::view-transition-group(vtr-17-state) {
animation-duration: 300ms;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-17-state) {
animation-duration: 1ms;
}
}
/* Theme switch (added 2026-08-24). Pure CSS: three radios + :has(). Auto
follows the OS and the site's [data-theme] stamp; Light and Dark pin the
palette. Values are the demo's own [data-theme] palettes reused verbatim,
so the toggle can never drift from the site-theme rendering. */
.vtr-17 {
position: relative;
}
.vtr-17__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.vtr-17__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-17__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-17__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.vtr-17__themeopt:hover {
opacity: 0.85;
}
.vtr-17__themeinput:checked + .vtr-17__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-17__themeinput:focus-visible + .vtr-17__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-17:has(#vtr-17-theme-light:checked) {
--bg: #f2f7f3;
--surface: #ffffff;
--line: #cfe3d6;
--ink: #0a1f14;
--muted: #4a6b58;
--accent: #0f7a43;
--accent-soft: #dff3e6;
}
.vtr-17:has(#vtr-17-theme-dark:checked) {
--bg: #05080a;
--surface: #0a1013;
--line: #16241f;
--ink: #d8f5e3;
--muted: #5c8a72;
--accent: #34e07f;
--accent-soft: #0d2418;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.vtr-17');
if (!root || root.dataset.vtr17) return;
root.dataset.vtr17 = 'on';
const state = root.querySelector('#vtr-17-state');
const branch = root.querySelector('#vtr-17-branch');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const supported = typeof document.startViewTransition === 'function';
root.querySelector('#vtr-17-api').textContent = supported ? 'available' : 'undefined';
root.querySelector('#vtr-17-rm').textContent = reduce.matches ? 'reduce' : 'no-preference';
const builds = [['build 4118 · queued', 'build 4118 · running on orbit-2'], ['build 4118 · running on orbit-2', 'build 4118 · passed in 2m 14s'], ['build 4118 · passed in 2m 14s', 'build 4118 · queued']];
let i = 0, running = false;
const update = () => { state.textContent = builds[i][1]; i = (i + 1) % builds.length; };
root.querySelector('#vtr-17-run').addEventListener('click', async () => {
if (!supported || reduce.matches) { update(); branch.textContent = 'guard hit · updated instantly'; return; }
if (running) { branch.textContent = 'in flight · call ignored'; return; }
running = true; branch.textContent = 'transition running';
try { await document.startViewTransition(update).finished; branch.textContent = 'transition finished'; }
finally { running = false; }
});
})();
```How this works
An unguarded call throws. In an engine without the API, document.startViewTransition is undefined, and calling it raises a TypeError that stops the rest of the script — so the DOM update never happens either. The page is not merely un-animated, it is broken. The guard has to be the first statement in the wrapper, before any snapshot setup, name assignment or type array is built.
The fallback path must be the correct path. The point of the guard is not to skip the animation, it is to still apply the change. update(); return; — the same function the callback would have run, called directly. If your update function only works because a transition is in flight, that is a design problem the guard will expose.
One wrapper, used everywhere. Writing the guard once and reusing it means every interaction in the codebase degrades identically, and a reader who copies two components gets the same behaviour in both. The wrapper here also folds in the reduced-motion check and the in-flight flag, so the three things that most often go wrong are handled in one place.
Detect the feature, not the browser. User-agent sniffing for Firefox will be wrong the moment it ships, and it is already wrong for older Chrome and Safari versions. The property check is accurate today and stays accurate. The same applies to the object form used by demo 05: if you need to know whether types are supported, check for the capability rather than assuming a version.
Make it yours
- Return the transition object from the wrapper so callers can await
finishedor callskipTransition(). - Add an optional types array to the wrapper for the direction-aware pattern from demo 05.
- Take an options object for per-call duration overrides instead of a global token.
- Log the branch taken in development builds and strip the logging in production.
- Wrap the guard in a small module and import it wherever a transition is started.
- Extend the guard to also bail out when the document is hidden, since a background tab has nothing to animate.
Gotchas — read before shipping
- Calling the API without the guard throws a TypeError in Firefox and kills the rest of the script, so the DOM update is lost too.
- Checking the browser instead of the feature breaks as soon as support changes.
- A guard placed after name assignment leaves
view-transition-namevalues attached in unsupporting engines. - Returning nothing from the wrapper means callers cannot await the transition or skip it.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 18+ | not yet | 111+ |
This demo exists because of the support gap it documents: document.startViewTransition() is Chrome 111 and Safari 18, and Firefox has not shipped it. The guard is the reason the collection works there at all — the DOM update runs immediately with no animation and no thrown error. The property check itself is plain JavaScript with no version floor, so the wrapper is safe to ship to every engine including ones with no View Transitions support whatsoever.