02 / 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
Cross Document MPA Transition
The piece most developers do not know exists: a single at-rule, @view-transition { navigation: auto; }, shipped in the stylesheet of both pages, and same-origin navigations animate with no JavaScript at all. Static sites, Astro, Rails and Django get SPA-feeling page changes for free. The preview swaps two documents inside one browser frame with a pure-CSS switch, because a sandboxed frame cannot perform a real navigation.
Build UI like this from scratch: Flexbox, Grid, animations & responsive CSS
ZTM · The CSS Bootcamp · 30‑day money-back guarantee · Affiliate link
The code
<section class="vtr-02" aria-labelledby="vtr-02-heading">
<fieldset class="vtr-02__theme">
<legend class="vtr-02__themelegend">Theme</legend>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-auto" checked>
<label class="vtr-02__themeopt" for="vtr-02-theme-auto">Auto</label>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-light">
<label class="vtr-02__themeopt" for="vtr-02-theme-light">Light</label>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-dark">
<label class="vtr-02__themeopt" for="vtr-02-theme-dark">Dark</label>
</fieldset>
<div class="vtr-02__stage">
<header>
<p class="vtr-02__kicker">Cross document · zero JavaScript</p>
<h2 class="vtr-02__heading" id="vtr-02-heading">Two documents, one at-rule</h2>
<p class="vtr-02__sub">Click the two filenames in the toolbar. The chrome persists, the document body changes — the shape of what the at-rule gives you for free.</p>
</header>
<pre class="vtr-02__rule"><code>@view-transition { navigation: auto; } /* in the stylesheet both pages load */</code></pre>
<div class="vtr-02__browser">
<input class="vtr-02__radio" type="radio" name="vtr-02-page" id="vtr-02-a" checked>
<input class="vtr-02__radio" type="radio" name="vtr-02-page" id="vtr-02-b">
<div class="vtr-02__bar">
<span class="vtr-02__dots" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="vtr-02__url" aria-hidden="true"><span>basalt.example / index.html</span><span>basalt.example / pricing.html</span></span>
<span class="vtr-02__links">
<label class="vtr-02__link" for="vtr-02-a">index.html</label>
<label class="vtr-02__link" for="vtr-02-b">pricing.html</label>
</span>
</div>
<div class="vtr-02__viewport">
<article class="vtr-02__page vtr-02__page--a">
<div class="vtr-02__body">
<p class="vtr-02__docname">Document one · outgoing</p>
<div class="vtr-02__hero"></div>
<h3 class="vtr-02__title">Basalt ships static, feels dynamic</h3>
<p class="vtr-02__copy">The old page is snapshotted before it unloads. Its stylesheet carries the rule, so the browser is allowed to take that snapshot.</p>
</div>
</article>
<article class="vtr-02__page vtr-02__page--b">
<div class="vtr-02__body">
<p class="vtr-02__docname">Document two · incoming</p>
<div class="vtr-02__hero"></div>
<h3 class="vtr-02__title">Pricing that reads in one screen</h3>
<p class="vtr-02__copy">The new page carries the rule too, so it accepts the transition and pairs any shared names with the snapshot it inherited.</p>
</div>
</article>
</div>
</div>
<ul class="vtr-02__reqs">
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>Same origin</strong><span>A navigation to another origin never transitions.</span></span>
</li>
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>Rule on both pages</strong><span>Outgoing and incoming documents each need it.</span></span>
</li>
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>No preventDefault</strong><span>A router that intercepts the click owns the navigation.</span></span>
</li>
</ul>
<p class="vtr-02__note">Honest caveat: this preview runs in a sandboxed frame with an opaque origin, so a <em>real</em> cross-document navigation cannot happen here — the toolbar switch is a pure-CSS stand-in (<code>:has(:checked)</code> plus a crossfade) for the effect. Ship the at-rule in two same-origin pages and the browser drives the identical change itself, with no switch and no script.</p>
</div>
</section><section class="vtr-02" aria-labelledby="vtr-02-heading">
<fieldset class="vtr-02__theme">
<legend class="vtr-02__themelegend">Theme</legend>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-auto" checked>
<label class="vtr-02__themeopt" for="vtr-02-theme-auto">Auto</label>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-light">
<label class="vtr-02__themeopt" for="vtr-02-theme-light">Light</label>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-dark">
<label class="vtr-02__themeopt" for="vtr-02-theme-dark">Dark</label>
</fieldset>
<div class="vtr-02__stage">
<header>
<p class="vtr-02__kicker">Cross document · zero JavaScript</p>
<h2 class="vtr-02__heading" id="vtr-02-heading">Two documents, one at-rule</h2>
<p class="vtr-02__sub">Click the two filenames in the toolbar. The chrome persists, the document body changes — the shape of what the at-rule gives you for free.</p>
</header>
<pre class="vtr-02__rule"><code>@view-transition { navigation: auto; } /* in the stylesheet both pages load */</code></pre>
<div class="vtr-02__browser">
<input class="vtr-02__radio" type="radio" name="vtr-02-page" id="vtr-02-a" checked>
<input class="vtr-02__radio" type="radio" name="vtr-02-page" id="vtr-02-b">
<div class="vtr-02__bar">
<span class="vtr-02__dots" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="vtr-02__url" aria-hidden="true"><span>basalt.example / index.html</span><span>basalt.example / pricing.html</span></span>
<span class="vtr-02__links">
<label class="vtr-02__link" for="vtr-02-a">index.html</label>
<label class="vtr-02__link" for="vtr-02-b">pricing.html</label>
</span>
</div>
<div class="vtr-02__viewport">
<article class="vtr-02__page vtr-02__page--a">
<div class="vtr-02__body">
<p class="vtr-02__docname">Document one · outgoing</p>
<div class="vtr-02__hero"></div>
<h3 class="vtr-02__title">Basalt ships static, feels dynamic</h3>
<p class="vtr-02__copy">The old page is snapshotted before it unloads. Its stylesheet carries the rule, so the browser is allowed to take that snapshot.</p>
</div>
</article>
<article class="vtr-02__page vtr-02__page--b">
<div class="vtr-02__body">
<p class="vtr-02__docname">Document two · incoming</p>
<div class="vtr-02__hero"></div>
<h3 class="vtr-02__title">Pricing that reads in one screen</h3>
<p class="vtr-02__copy">The new page carries the rule too, so it accepts the transition and pairs any shared names with the snapshot it inherited.</p>
</div>
</article>
</div>
</div>
<ul class="vtr-02__reqs">
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>Same origin</strong><span>A navigation to another origin never transitions.</span></span>
</li>
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>Rule on both pages</strong><span>Outgoing and incoming documents each need it.</span></span>
</li>
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>No preventDefault</strong><span>A router that intercepts the click owns the navigation.</span></span>
</li>
</ul>
<p class="vtr-02__note">Honest caveat: this preview runs in a sandboxed frame with an opaque origin, so a <em>real</em> cross-document navigation cannot happen here — the toolbar switch is a pure-CSS stand-in (<code>:has(:checked)</code> plus a crossfade) for the effect. Ship the at-rule in two same-origin pages and the browser drives the identical change itself, with no switch and no script.</p>
</div>
</section>.vtr-02 {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
--font-display: system-ui, "Segoe UI", sans-serif;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
color: var(--ink);
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
.vtr-02 *,
.vtr-02 *::before,
.vtr-02 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-02 {
--bg: oklch(95.5% 0.018 254);
--surface: oklch(100% 0 0);
--chrome: oklch(97.5% 0.012 254);
--line: oklch(88% 0.03 254);
--ink: oklch(21% 0.045 262);
--muted: oklch(52% 0.04 258);
--accent: oklch(50% 0.2 265);
--accent-soft: oklch(93.5% 0.04 262);
}
}
@media (prefers-color-scheme: dark) {
.vtr-02 {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
}
.vtr-02[data-theme="light"] {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
}
.vtr-02[data-theme="dark"] {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
.vtr-02__stage {
max-width: 66rem;
margin: 0 auto;
padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.5rem);
display: grid;
gap: 1.5rem;
}
.vtr-02__kicker {
margin: 0 0 0.4rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-02__heading {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.4rem, 4.4vw, 2.1rem);
font-weight: 700;
letter-spacing: -0.025em;
}
.vtr-02__sub {
margin: 0.45rem 0 0;
color: var(--muted);
font-size: 0.92rem;
max-width: 40rem;
}
.vtr-02__browser {
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--surface);
overflow: hidden;
min-inline-size: 0;
box-shadow: 0 1px 2px rgba(16, 26, 44, 0.06), 0 8px 24px -18px rgba(16, 26, 44, 0.4);
}
.vtr-02__radio {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
clip-path: inset(50%);
overflow: hidden;
}
.vtr-02__bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.55rem 0.7rem;
background: var(--chrome);
border-bottom: 1px solid var(--line);
}
.vtr-02__links {
display: flex;
gap: 0.3rem;
}
.vtr-02__link {
padding: 0.3rem 0.6rem;
border-radius: 0.3rem;
cursor: pointer;
border: 1px solid transparent;
color: var(--muted);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.68rem;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.vtr-02__link:hover {
color: var(--ink);
background: var(--surface);
}
.vtr-02__browser:has(#vtr-02-a:checked) [for="vtr-02-a"],
.vtr-02__browser:has(#vtr-02-b:checked) [for="vtr-02-b"] {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--line);
}
.vtr-02__browser:has(#vtr-02-a:focus-visible) [for="vtr-02-a"],
.vtr-02__browser:has(#vtr-02-b:focus-visible) [for="vtr-02-b"] {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vtr-02__dots {
display: flex;
gap: 0.3rem;
}
.vtr-02__dots span {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--line);
}
.vtr-02__url {
flex: 1 1 12rem;
min-inline-size: 0;
display: grid;
padding: 0.3rem 0.55rem;
border-radius: 0.3rem;
background: var(--surface);
border: 1px solid var(--line);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.vtr-02__url span {
grid-area: 1 / 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0;
transition: opacity 0.28s ease;
}
.vtr-02__browser:has(#vtr-02-a:checked) .vtr-02__url span:first-child,
.vtr-02__browser:has(#vtr-02-b:checked) .vtr-02__url span:last-child {
opacity: 1;
}
.vtr-02__viewport {
display: grid;
}
.vtr-02__page {
grid-area: 1 / 1;
min-inline-size: 0;
opacity: 0;
visibility: hidden;
transform: translateY(0.6rem) scale(0.99);
transition: opacity 0.34s ease, transform 0.34s cubic-bezier(0.2, 0.7, 0.2, 1), visibility 0.34s;
}
.vtr-02__browser:has(#vtr-02-a:checked) .vtr-02__page--a,
.vtr-02__browser:has(#vtr-02-b:checked) .vtr-02__page--b {
opacity: 1;
visibility: visible;
transform: none;
}
.vtr-02__body {
padding: 1rem 1.1rem 1.25rem;
display: grid;
gap: 0.7rem;
}
.vtr-02__docname {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.vtr-02__title {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.015em;
}
.vtr-02__hero {
height: 4.5rem;
border-radius: 0.4rem;
background: linear-gradient(120deg, #1d4ed8, #4f8dff 55%, #a6c4ff);
}
.vtr-02__page--b .vtr-02__hero {
background: linear-gradient(120deg, #0f766e, #2dd4bf 60%, #a7f3d0);
}
.vtr-02__copy {
margin: 0;
font-size: 0.8rem;
line-height: 1.55;
color: var(--muted);
}
.vtr-02__rule {
margin: 0;
padding: 0.75rem 0.9rem;
overflow-x: auto;
border: 1px dashed var(--line);
border-radius: 0.4rem;
background: var(--accent-soft);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.7rem;
line-height: 1.6;
color: var(--ink);
}
.vtr-02__reqs {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.55rem;
grid-template-columns: 1fr;
}
@media (min-width: 40rem) {
.vtr-02__reqs {
grid-template-columns: repeat(3, 1fr);
}
}
.vtr-02__req {
display: grid;
grid-template-columns: 1.1rem 1fr;
gap: 0.5rem;
padding: 0.7rem 0.8rem;
border-radius: 0.5rem;
border: 1px solid var(--line);
background: var(--surface);
font-size: 0.78rem;
line-height: 1.45;
min-inline-size: 0;
}
.vtr-02__req svg {
color: var(--accent);
margin-top: 0.1rem;
}
.vtr-02__req strong {
display: block;
font-size: 0.78rem;
}
.vtr-02__req span {
color: var(--muted);
}
.vtr-02__note {
margin: 0;
padding: 0.7rem 0.85rem;
border-left: 2px solid var(--accent);
background: var(--accent-soft);
font-size: 0.78rem;
line-height: 1.5;
color: var(--ink);
}
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
.vtr-02__page,
.vtr-02__url span,
.vtr-02__link {
transition-duration: 1ms;
}
::view-transition-group(*) {
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-02 {
position: relative;
}
.vtr-02__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-02__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-02__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-02__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-02__themeopt:hover {
opacity: 0.85;
}
.vtr-02__themeinput:checked + .vtr-02__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-02__themeinput:focus-visible + .vtr-02__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-02:has(#vtr-02-theme-light:checked) {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
}
.vtr-02:has(#vtr-02-theme-dark:checked) {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}.vtr-02 {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
--font-display: system-ui, "Segoe UI", sans-serif;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
color: var(--ink);
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
.vtr-02 *,
.vtr-02 *::before,
.vtr-02 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-02 {
--bg: oklch(95.5% 0.018 254);
--surface: oklch(100% 0 0);
--chrome: oklch(97.5% 0.012 254);
--line: oklch(88% 0.03 254);
--ink: oklch(21% 0.045 262);
--muted: oklch(52% 0.04 258);
--accent: oklch(50% 0.2 265);
--accent-soft: oklch(93.5% 0.04 262);
}
}
@media (prefers-color-scheme: dark) {
.vtr-02 {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
}
.vtr-02[data-theme="light"] {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
}
.vtr-02[data-theme="dark"] {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
.vtr-02__stage {
max-width: 66rem;
margin: 0 auto;
padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.5rem);
display: grid;
gap: 1.5rem;
}
.vtr-02__kicker {
margin: 0 0 0.4rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-02__heading {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.4rem, 4.4vw, 2.1rem);
font-weight: 700;
letter-spacing: -0.025em;
}
.vtr-02__sub {
margin: 0.45rem 0 0;
color: var(--muted);
font-size: 0.92rem;
max-width: 40rem;
}
.vtr-02__browser {
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--surface);
overflow: hidden;
min-inline-size: 0;
box-shadow: 0 1px 2px rgba(16, 26, 44, 0.06), 0 8px 24px -18px rgba(16, 26, 44, 0.4);
}
.vtr-02__radio {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
clip-path: inset(50%);
overflow: hidden;
}
.vtr-02__bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.55rem 0.7rem;
background: var(--chrome);
border-bottom: 1px solid var(--line);
}
.vtr-02__links {
display: flex;
gap: 0.3rem;
}
.vtr-02__link {
padding: 0.3rem 0.6rem;
border-radius: 0.3rem;
cursor: pointer;
border: 1px solid transparent;
color: var(--muted);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.68rem;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.vtr-02__link:hover {
color: var(--ink);
background: var(--surface);
}
.vtr-02__browser:has(#vtr-02-a:checked) [for="vtr-02-a"],
.vtr-02__browser:has(#vtr-02-b:checked) [for="vtr-02-b"] {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--line);
}
.vtr-02__browser:has(#vtr-02-a:focus-visible) [for="vtr-02-a"],
.vtr-02__browser:has(#vtr-02-b:focus-visible) [for="vtr-02-b"] {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vtr-02__dots {
display: flex;
gap: 0.3rem;
}
.vtr-02__dots span {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--line);
}
.vtr-02__url {
flex: 1 1 12rem;
min-inline-size: 0;
display: grid;
padding: 0.3rem 0.55rem;
border-radius: 0.3rem;
background: var(--surface);
border: 1px solid var(--line);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.vtr-02__url span {
grid-area: 1 / 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0;
transition: opacity 0.28s ease;
}
.vtr-02__browser:has(#vtr-02-a:checked) .vtr-02__url span:first-child,
.vtr-02__browser:has(#vtr-02-b:checked) .vtr-02__url span:last-child {
opacity: 1;
}
.vtr-02__viewport {
display: grid;
}
.vtr-02__page {
grid-area: 1 / 1;
min-inline-size: 0;
opacity: 0;
visibility: hidden;
transform: translateY(0.6rem) scale(0.99);
transition: opacity 0.34s ease, transform 0.34s cubic-bezier(0.2, 0.7, 0.2, 1), visibility 0.34s;
}
.vtr-02__browser:has(#vtr-02-a:checked) .vtr-02__page--a,
.vtr-02__browser:has(#vtr-02-b:checked) .vtr-02__page--b {
opacity: 1;
visibility: visible;
transform: none;
}
.vtr-02__body {
padding: 1rem 1.1rem 1.25rem;
display: grid;
gap: 0.7rem;
}
.vtr-02__docname {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.vtr-02__title {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.015em;
}
.vtr-02__hero {
height: 4.5rem;
border-radius: 0.4rem;
background: linear-gradient(120deg, #1d4ed8, #4f8dff 55%, #a6c4ff);
}
.vtr-02__page--b .vtr-02__hero {
background: linear-gradient(120deg, #0f766e, #2dd4bf 60%, #a7f3d0);
}
.vtr-02__copy {
margin: 0;
font-size: 0.8rem;
line-height: 1.55;
color: var(--muted);
}
.vtr-02__rule {
margin: 0;
padding: 0.75rem 0.9rem;
overflow-x: auto;
border: 1px dashed var(--line);
border-radius: 0.4rem;
background: var(--accent-soft);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.7rem;
line-height: 1.6;
color: var(--ink);
}
.vtr-02__reqs {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.55rem;
grid-template-columns: 1fr;
}
@media (min-width: 40rem) {
.vtr-02__reqs {
grid-template-columns: repeat(3, 1fr);
}
}
.vtr-02__req {
display: grid;
grid-template-columns: 1.1rem 1fr;
gap: 0.5rem;
padding: 0.7rem 0.8rem;
border-radius: 0.5rem;
border: 1px solid var(--line);
background: var(--surface);
font-size: 0.78rem;
line-height: 1.45;
min-inline-size: 0;
}
.vtr-02__req svg {
color: var(--accent);
margin-top: 0.1rem;
}
.vtr-02__req strong {
display: block;
font-size: 0.78rem;
}
.vtr-02__req span {
color: var(--muted);
}
.vtr-02__note {
margin: 0;
padding: 0.7rem 0.85rem;
border-left: 2px solid var(--accent);
background: var(--accent-soft);
font-size: 0.78rem;
line-height: 1.5;
color: var(--ink);
}
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
.vtr-02__page,
.vtr-02__url span,
.vtr-02__link {
transition-duration: 1ms;
}
::view-transition-group(*) {
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-02 {
position: relative;
}
.vtr-02__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-02__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-02__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-02__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-02__themeopt:hover {
opacity: 0.85;
}
.vtr-02__themeinput:checked + .vtr-02__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-02__themeinput:focus-visible + .vtr-02__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-02:has(#vtr-02-theme-light:checked) {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
}
.vtr-02:has(#vtr-02-theme-dark:checked) {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}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: Cross Document MPA Transition
Source: https://codefronts.com/motion/css-view-transitions/cross-document-mpa-transition/
The piece most developers do not know exists: a single at-rule, @view-transition { navigation: auto; }, shipped in the stylesheet of both pages, and same-origin navigations animate with no JavaScript at all. Static sites, Astro, Rails and Django get SPA-feeling page changes for free. The preview swaps two documents inside one browser frame with a pure-CSS switch, because a sandboxed frame cannot perform a real navigation.
## HTML
```html
<section class="vtr-02" aria-labelledby="vtr-02-heading">
<fieldset class="vtr-02__theme">
<legend class="vtr-02__themelegend">Theme</legend>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-auto" checked>
<label class="vtr-02__themeopt" for="vtr-02-theme-auto">Auto</label>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-light">
<label class="vtr-02__themeopt" for="vtr-02-theme-light">Light</label>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-dark">
<label class="vtr-02__themeopt" for="vtr-02-theme-dark">Dark</label>
</fieldset>
<div class="vtr-02__stage">
<header>
<p class="vtr-02__kicker">Cross document · zero JavaScript</p>
<h2 class="vtr-02__heading" id="vtr-02-heading">Two documents, one at-rule</h2>
<p class="vtr-02__sub">Click the two filenames in the toolbar. The chrome persists, the document body changes — the shape of what the at-rule gives you for free.</p>
</header>
<pre class="vtr-02__rule"><code>@view-transition { navigation: auto; } /* in the stylesheet both pages load */</code></pre>
<div class="vtr-02__browser">
<input class="vtr-02__radio" type="radio" name="vtr-02-page" id="vtr-02-a" checked>
<input class="vtr-02__radio" type="radio" name="vtr-02-page" id="vtr-02-b">
<div class="vtr-02__bar">
<span class="vtr-02__dots" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="vtr-02__url" aria-hidden="true"><span>basalt.example / index.html</span><span>basalt.example / pricing.html</span></span>
<span class="vtr-02__links">
<label class="vtr-02__link" for="vtr-02-a">index.html</label>
<label class="vtr-02__link" for="vtr-02-b">pricing.html</label>
</span>
</div>
<div class="vtr-02__viewport">
<article class="vtr-02__page vtr-02__page--a">
<div class="vtr-02__body">
<p class="vtr-02__docname">Document one · outgoing</p>
<div class="vtr-02__hero"></div>
<h3 class="vtr-02__title">Basalt ships static, feels dynamic</h3>
<p class="vtr-02__copy">The old page is snapshotted before it unloads. Its stylesheet carries the rule, so the browser is allowed to take that snapshot.</p>
</div>
</article>
<article class="vtr-02__page vtr-02__page--b">
<div class="vtr-02__body">
<p class="vtr-02__docname">Document two · incoming</p>
<div class="vtr-02__hero"></div>
<h3 class="vtr-02__title">Pricing that reads in one screen</h3>
<p class="vtr-02__copy">The new page carries the rule too, so it accepts the transition and pairs any shared names with the snapshot it inherited.</p>
</div>
</article>
</div>
</div>
<ul class="vtr-02__reqs">
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>Same origin</strong><span>A navigation to another origin never transitions.</span></span>
</li>
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>Rule on both pages</strong><span>Outgoing and incoming documents each need it.</span></span>
</li>
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>No preventDefault</strong><span>A router that intercepts the click owns the navigation.</span></span>
</li>
</ul>
<p class="vtr-02__note">Honest caveat: this preview runs in a sandboxed frame with an opaque origin, so a <em>real</em> cross-document navigation cannot happen here — the toolbar switch is a pure-CSS stand-in (<code>:has(:checked)</code> plus a crossfade) for the effect. Ship the at-rule in two same-origin pages and the browser drives the identical change itself, with no switch and no script.</p>
</div>
</section>
```
## CSS
```css
.vtr-02 {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
--font-display: system-ui, "Segoe UI", sans-serif;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
color: var(--ink);
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
.vtr-02 *,
.vtr-02 *::before,
.vtr-02 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-02 {
--bg: oklch(95.5% 0.018 254);
--surface: oklch(100% 0 0);
--chrome: oklch(97.5% 0.012 254);
--line: oklch(88% 0.03 254);
--ink: oklch(21% 0.045 262);
--muted: oklch(52% 0.04 258);
--accent: oklch(50% 0.2 265);
--accent-soft: oklch(93.5% 0.04 262);
}
}
@media (prefers-color-scheme: dark) {
.vtr-02 {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
}
.vtr-02[data-theme="light"] {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
}
.vtr-02[data-theme="dark"] {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
.vtr-02__stage {
max-width: 66rem;
margin: 0 auto;
padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.5rem);
display: grid;
gap: 1.5rem;
}
.vtr-02__kicker {
margin: 0 0 0.4rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-02__heading {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.4rem, 4.4vw, 2.1rem);
font-weight: 700;
letter-spacing: -0.025em;
}
.vtr-02__sub {
margin: 0.45rem 0 0;
color: var(--muted);
font-size: 0.92rem;
max-width: 40rem;
}
.vtr-02__browser {
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--surface);
overflow: hidden;
min-inline-size: 0;
box-shadow: 0 1px 2px rgba(16, 26, 44, 0.06), 0 8px 24px -18px rgba(16, 26, 44, 0.4);
}
.vtr-02__radio {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
clip-path: inset(50%);
overflow: hidden;
}
.vtr-02__bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.55rem 0.7rem;
background: var(--chrome);
border-bottom: 1px solid var(--line);
}
.vtr-02__links {
display: flex;
gap: 0.3rem;
}
.vtr-02__link {
padding: 0.3rem 0.6rem;
border-radius: 0.3rem;
cursor: pointer;
border: 1px solid transparent;
color: var(--muted);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.68rem;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.vtr-02__link:hover {
color: var(--ink);
background: var(--surface);
}
.vtr-02__browser:has(#vtr-02-a:checked) [for="vtr-02-a"],
.vtr-02__browser:has(#vtr-02-b:checked) [for="vtr-02-b"] {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--line);
}
.vtr-02__browser:has(#vtr-02-a:focus-visible) [for="vtr-02-a"],
.vtr-02__browser:has(#vtr-02-b:focus-visible) [for="vtr-02-b"] {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vtr-02__dots {
display: flex;
gap: 0.3rem;
}
.vtr-02__dots span {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--line);
}
.vtr-02__url {
flex: 1 1 12rem;
min-inline-size: 0;
display: grid;
padding: 0.3rem 0.55rem;
border-radius: 0.3rem;
background: var(--surface);
border: 1px solid var(--line);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.vtr-02__url span {
grid-area: 1 / 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0;
transition: opacity 0.28s ease;
}
.vtr-02__browser:has(#vtr-02-a:checked) .vtr-02__url span:first-child,
.vtr-02__browser:has(#vtr-02-b:checked) .vtr-02__url span:last-child {
opacity: 1;
}
.vtr-02__viewport {
display: grid;
}
.vtr-02__page {
grid-area: 1 / 1;
min-inline-size: 0;
opacity: 0;
visibility: hidden;
transform: translateY(0.6rem) scale(0.99);
transition: opacity 0.34s ease, transform 0.34s cubic-bezier(0.2, 0.7, 0.2, 1), visibility 0.34s;
}
.vtr-02__browser:has(#vtr-02-a:checked) .vtr-02__page--a,
.vtr-02__browser:has(#vtr-02-b:checked) .vtr-02__page--b {
opacity: 1;
visibility: visible;
transform: none;
}
.vtr-02__body {
padding: 1rem 1.1rem 1.25rem;
display: grid;
gap: 0.7rem;
}
.vtr-02__docname {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.vtr-02__title {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.015em;
}
.vtr-02__hero {
height: 4.5rem;
border-radius: 0.4rem;
background: linear-gradient(120deg, #1d4ed8, #4f8dff 55%, #a6c4ff);
}
.vtr-02__page--b .vtr-02__hero {
background: linear-gradient(120deg, #0f766e, #2dd4bf 60%, #a7f3d0);
}
.vtr-02__copy {
margin: 0;
font-size: 0.8rem;
line-height: 1.55;
color: var(--muted);
}
.vtr-02__rule {
margin: 0;
padding: 0.75rem 0.9rem;
overflow-x: auto;
border: 1px dashed var(--line);
border-radius: 0.4rem;
background: var(--accent-soft);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.7rem;
line-height: 1.6;
color: var(--ink);
}
.vtr-02__reqs {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.55rem;
grid-template-columns: 1fr;
}
@media (min-width: 40rem) {
.vtr-02__reqs {
grid-template-columns: repeat(3, 1fr);
}
}
.vtr-02__req {
display: grid;
grid-template-columns: 1.1rem 1fr;
gap: 0.5rem;
padding: 0.7rem 0.8rem;
border-radius: 0.5rem;
border: 1px solid var(--line);
background: var(--surface);
font-size: 0.78rem;
line-height: 1.45;
min-inline-size: 0;
}
.vtr-02__req svg {
color: var(--accent);
margin-top: 0.1rem;
}
.vtr-02__req strong {
display: block;
font-size: 0.78rem;
}
.vtr-02__req span {
color: var(--muted);
}
.vtr-02__note {
margin: 0;
padding: 0.7rem 0.85rem;
border-left: 2px solid var(--accent);
background: var(--accent-soft);
font-size: 0.78rem;
line-height: 1.5;
color: var(--ink);
}
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
.vtr-02__page,
.vtr-02__url span,
.vtr-02__link {
transition-duration: 1ms;
}
::view-transition-group(*) {
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-02 {
position: relative;
}
.vtr-02__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-02__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-02__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-02__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-02__themeopt:hover {
opacity: 0.85;
}
.vtr-02__themeinput:checked + .vtr-02__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-02__themeinput:focus-visible + .vtr-02__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-02:has(#vtr-02-theme-light:checked) {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
}
.vtr-02:has(#vtr-02-theme-dark:checked) {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
```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: Cross Document MPA Transition
Source: https://codefronts.com/motion/css-view-transitions/cross-document-mpa-transition/
The piece most developers do not know exists: a single at-rule, @view-transition { navigation: auto; }, shipped in the stylesheet of both pages, and same-origin navigations animate with no JavaScript at all. Static sites, Astro, Rails and Django get SPA-feeling page changes for free. The preview swaps two documents inside one browser frame with a pure-CSS switch, because a sandboxed frame cannot perform a real navigation.
## HTML
```html
<section class="vtr-02" aria-labelledby="vtr-02-heading">
<fieldset class="vtr-02__theme">
<legend class="vtr-02__themelegend">Theme</legend>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-auto" checked>
<label class="vtr-02__themeopt" for="vtr-02-theme-auto">Auto</label>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-light">
<label class="vtr-02__themeopt" for="vtr-02-theme-light">Light</label>
<input class="vtr-02__themeinput" type="radio" name="vtr-02-theme" id="vtr-02-theme-dark">
<label class="vtr-02__themeopt" for="vtr-02-theme-dark">Dark</label>
</fieldset>
<div class="vtr-02__stage">
<header>
<p class="vtr-02__kicker">Cross document · zero JavaScript</p>
<h2 class="vtr-02__heading" id="vtr-02-heading">Two documents, one at-rule</h2>
<p class="vtr-02__sub">Click the two filenames in the toolbar. The chrome persists, the document body changes — the shape of what the at-rule gives you for free.</p>
</header>
<pre class="vtr-02__rule"><code>@view-transition { navigation: auto; } /* in the stylesheet both pages load */</code></pre>
<div class="vtr-02__browser">
<input class="vtr-02__radio" type="radio" name="vtr-02-page" id="vtr-02-a" checked>
<input class="vtr-02__radio" type="radio" name="vtr-02-page" id="vtr-02-b">
<div class="vtr-02__bar">
<span class="vtr-02__dots" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="vtr-02__url" aria-hidden="true"><span>basalt.example / index.html</span><span>basalt.example / pricing.html</span></span>
<span class="vtr-02__links">
<label class="vtr-02__link" for="vtr-02-a">index.html</label>
<label class="vtr-02__link" for="vtr-02-b">pricing.html</label>
</span>
</div>
<div class="vtr-02__viewport">
<article class="vtr-02__page vtr-02__page--a">
<div class="vtr-02__body">
<p class="vtr-02__docname">Document one · outgoing</p>
<div class="vtr-02__hero"></div>
<h3 class="vtr-02__title">Basalt ships static, feels dynamic</h3>
<p class="vtr-02__copy">The old page is snapshotted before it unloads. Its stylesheet carries the rule, so the browser is allowed to take that snapshot.</p>
</div>
</article>
<article class="vtr-02__page vtr-02__page--b">
<div class="vtr-02__body">
<p class="vtr-02__docname">Document two · incoming</p>
<div class="vtr-02__hero"></div>
<h3 class="vtr-02__title">Pricing that reads in one screen</h3>
<p class="vtr-02__copy">The new page carries the rule too, so it accepts the transition and pairs any shared names with the snapshot it inherited.</p>
</div>
</article>
</div>
</div>
<ul class="vtr-02__reqs">
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>Same origin</strong><span>A navigation to another origin never transitions.</span></span>
</li>
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>Rule on both pages</strong><span>Outgoing and incoming documents each need it.</span></span>
</li>
<li class="vtr-02__req">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2 8.4l3.4 3.4L14 3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span><strong>No preventDefault</strong><span>A router that intercepts the click owns the navigation.</span></span>
</li>
</ul>
<p class="vtr-02__note">Honest caveat: this preview runs in a sandboxed frame with an opaque origin, so a <em>real</em> cross-document navigation cannot happen here — the toolbar switch is a pure-CSS stand-in (<code>:has(:checked)</code> plus a crossfade) for the effect. Ship the at-rule in two same-origin pages and the browser drives the identical change itself, with no switch and no script.</p>
</div>
</section>
```
## CSS
```css
.vtr-02 {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
--font-display: system-ui, "Segoe UI", sans-serif;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
color: var(--ink);
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
.vtr-02 *,
.vtr-02 *::before,
.vtr-02 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-02 {
--bg: oklch(95.5% 0.018 254);
--surface: oklch(100% 0 0);
--chrome: oklch(97.5% 0.012 254);
--line: oklch(88% 0.03 254);
--ink: oklch(21% 0.045 262);
--muted: oklch(52% 0.04 258);
--accent: oklch(50% 0.2 265);
--accent-soft: oklch(93.5% 0.04 262);
}
}
@media (prefers-color-scheme: dark) {
.vtr-02 {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
}
.vtr-02[data-theme="light"] {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
}
.vtr-02[data-theme="dark"] {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
.vtr-02__stage {
max-width: 66rem;
margin: 0 auto;
padding: clamp(1.25rem, 4vw, 3rem) clamp(1rem, 4vw, 2.5rem);
display: grid;
gap: 1.5rem;
}
.vtr-02__kicker {
margin: 0 0 0.4rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-02__heading {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.4rem, 4.4vw, 2.1rem);
font-weight: 700;
letter-spacing: -0.025em;
}
.vtr-02__sub {
margin: 0.45rem 0 0;
color: var(--muted);
font-size: 0.92rem;
max-width: 40rem;
}
.vtr-02__browser {
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--surface);
overflow: hidden;
min-inline-size: 0;
box-shadow: 0 1px 2px rgba(16, 26, 44, 0.06), 0 8px 24px -18px rgba(16, 26, 44, 0.4);
}
.vtr-02__radio {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
clip-path: inset(50%);
overflow: hidden;
}
.vtr-02__bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.55rem 0.7rem;
background: var(--chrome);
border-bottom: 1px solid var(--line);
}
.vtr-02__links {
display: flex;
gap: 0.3rem;
}
.vtr-02__link {
padding: 0.3rem 0.6rem;
border-radius: 0.3rem;
cursor: pointer;
border: 1px solid transparent;
color: var(--muted);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.68rem;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.vtr-02__link:hover {
color: var(--ink);
background: var(--surface);
}
.vtr-02__browser:has(#vtr-02-a:checked) [for="vtr-02-a"],
.vtr-02__browser:has(#vtr-02-b:checked) [for="vtr-02-b"] {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--line);
}
.vtr-02__browser:has(#vtr-02-a:focus-visible) [for="vtr-02-a"],
.vtr-02__browser:has(#vtr-02-b:focus-visible) [for="vtr-02-b"] {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vtr-02__dots {
display: flex;
gap: 0.3rem;
}
.vtr-02__dots span {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--line);
}
.vtr-02__url {
flex: 1 1 12rem;
min-inline-size: 0;
display: grid;
padding: 0.3rem 0.55rem;
border-radius: 0.3rem;
background: var(--surface);
border: 1px solid var(--line);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.68rem;
color: var(--muted);
}
.vtr-02__url span {
grid-area: 1 / 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0;
transition: opacity 0.28s ease;
}
.vtr-02__browser:has(#vtr-02-a:checked) .vtr-02__url span:first-child,
.vtr-02__browser:has(#vtr-02-b:checked) .vtr-02__url span:last-child {
opacity: 1;
}
.vtr-02__viewport {
display: grid;
}
.vtr-02__page {
grid-area: 1 / 1;
min-inline-size: 0;
opacity: 0;
visibility: hidden;
transform: translateY(0.6rem) scale(0.99);
transition: opacity 0.34s ease, transform 0.34s cubic-bezier(0.2, 0.7, 0.2, 1), visibility 0.34s;
}
.vtr-02__browser:has(#vtr-02-a:checked) .vtr-02__page--a,
.vtr-02__browser:has(#vtr-02-b:checked) .vtr-02__page--b {
opacity: 1;
visibility: visible;
transform: none;
}
.vtr-02__body {
padding: 1rem 1.1rem 1.25rem;
display: grid;
gap: 0.7rem;
}
.vtr-02__docname {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.vtr-02__title {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.015em;
}
.vtr-02__hero {
height: 4.5rem;
border-radius: 0.4rem;
background: linear-gradient(120deg, #1d4ed8, #4f8dff 55%, #a6c4ff);
}
.vtr-02__page--b .vtr-02__hero {
background: linear-gradient(120deg, #0f766e, #2dd4bf 60%, #a7f3d0);
}
.vtr-02__copy {
margin: 0;
font-size: 0.8rem;
line-height: 1.55;
color: var(--muted);
}
.vtr-02__rule {
margin: 0;
padding: 0.75rem 0.9rem;
overflow-x: auto;
border: 1px dashed var(--line);
border-radius: 0.4rem;
background: var(--accent-soft);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.7rem;
line-height: 1.6;
color: var(--ink);
}
.vtr-02__reqs {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.55rem;
grid-template-columns: 1fr;
}
@media (min-width: 40rem) {
.vtr-02__reqs {
grid-template-columns: repeat(3, 1fr);
}
}
.vtr-02__req {
display: grid;
grid-template-columns: 1.1rem 1fr;
gap: 0.5rem;
padding: 0.7rem 0.8rem;
border-radius: 0.5rem;
border: 1px solid var(--line);
background: var(--surface);
font-size: 0.78rem;
line-height: 1.45;
min-inline-size: 0;
}
.vtr-02__req svg {
color: var(--accent);
margin-top: 0.1rem;
}
.vtr-02__req strong {
display: block;
font-size: 0.78rem;
}
.vtr-02__req span {
color: var(--muted);
}
.vtr-02__note {
margin: 0;
padding: 0.7rem 0.85rem;
border-left: 2px solid var(--accent);
background: var(--accent-soft);
font-size: 0.78rem;
line-height: 1.5;
color: var(--ink);
}
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
.vtr-02__page,
.vtr-02__url span,
.vtr-02__link {
transition-duration: 1ms;
}
::view-transition-group(*) {
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-02 {
position: relative;
}
.vtr-02__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-02__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-02__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-02__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-02__themeopt:hover {
opacity: 0.85;
}
.vtr-02__themeinput:checked + .vtr-02__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-02__themeinput:focus-visible + .vtr-02__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-02:has(#vtr-02-theme-light:checked) {
--bg: #eef3fb;
--surface: #ffffff;
--chrome: #f4f7fd;
--line: #d3ddee;
--ink: #101a2c;
--muted: #5b6a85;
--accent: #1d4ed8;
--accent-soft: #e3ecff;
}
.vtr-02:has(#vtr-02-theme-dark:checked) {
--bg: #0a1020;
--surface: #121b30;
--chrome: #16203a;
--line: #26324f;
--ink: #e8eefb;
--muted: #93a3c2;
--accent: #7ea2ff;
--accent-soft: #1c2949;
}
```How this works
Cross-document transitions are opt-in on both sides of the navigation. The rule is @view-transition { navigation: auto; } and it must appear in a stylesheet that both the page you leave and the page you arrive at load. One side alone does nothing: the outgoing document needs the rule to allow its snapshot to be taken, and the incoming document needs it to accept the transition. In practice you put it in the shared global stylesheet and forget about it.
The navigation must be same-origin and not intercepted. A link to another origin never transitions, and neither does a navigation you cancelled with preventDefault() or hijacked with a router. Form submissions and back/forward navigations do transition, which is why this is the cheapest possible upgrade for a server-rendered site — no router, no client-side framework, no hydration.
Shared elements pair by name across documents. A site header with view-transition-name: vtr-02-bar on page A and the same name on page B is treated as one element that moved, so the header appears to persist while the body content cross-fades. This is the effect people build entire SPA frameworks to get, and here it is two declarations.
What the preview actually runs. The demo frame is sandboxed with an opaque origin, so the same-origin condition can never be met inside it and a real navigation is impossible. Instead two documents share one browser chrome and a hidden radio pair switches between them: .vtr-02__browser:has(#vtr-02-b:checked) .vtr-02__page--b reveals the incoming document while the toolbar stays put. That is a CSS crossfade standing in for the real thing — no startViewTransition(), no script. On a real site you delete the switch, keep the at-rule, and the browser produces the same persistence-plus-crossfade on the navigation itself; where the rule is unsupported the navigation simply completes without animation.
Make it yours
- Set
navigation: noneto opt a specific page out while the rest of the site keeps transitioning. - Add
view-transition-nameto a persistent sidebar so it stays put while the main column changes. - Scope the rule with a media query so cross-document transitions only run on wide viewports.
- Change
--accentto restyle the simulated browser chrome and the active toolbar link. - Pair the rule with
:active-view-transition-type()from demo 05 for direction-aware page changes. - Swap the checklist copy for your own deployment requirements — the layout is a plain grid.
Gotchas — read before shipping
- The rule on only one of the two documents produces no transition and no warning; both the outgoing and incoming page need it.
- Cross-origin navigations never transition, regardless of the rule — this is a security boundary, not a bug.
- A router that calls
preventDefault()on the click takes over the navigation, so the cross-document path never runs; usedocument.startViewTransition()there instead. - Cross-document support landed later than the same-document API, so an engine that supports
document.startViewTransition()may still ignore this rule.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 126+ | 18.2+ | not yet | 126+ |
The floor is @view-transition { navigation: auto }, which shipped later than the same-document API: Chrome 126 and Safari 18.2, with Firefox not yet shipping any part of View Transitions. Do not assume Chrome 111 here — that version supports document.startViewTransition() but ignores this at-rule. Where the rule is ignored the navigation still completes normally, so this is pure progressive enhancement with no JavaScript fallback to write. The :has() switch used to simulate the two documents needs Chrome 105, Safari 15.4 or Firefox 121.