15 / 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
Multi Step Form Wizard
Step two slides in from the right on Next and from the left on Back, with the step indicator travelling as its own named element. The form itself is deliberately plain — two fields per step — because the subject is the step-change mechanism and the direction-aware branch, not the form design.
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-15" aria-labelledby="vtr-15-heading">
<fieldset class="vtr-15__theme">
<legend class="vtr-15__themelegend">Theme</legend>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-auto" checked>
<label class="vtr-15__themeopt" for="vtr-15-theme-auto">Auto</label>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-light">
<label class="vtr-15__themeopt" for="vtr-15-theme-light">Light</label>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-dark">
<label class="vtr-15__themeopt" for="vtr-15-theme-dark">Dark</label>
</fieldset>
<div class="vtr-15__stage">
<header class="vtr-15__head">
<p class="vtr-15__kicker">Step mechanism</p>
<h2 class="vtr-15__heading" id="vtr-15-heading">Direction as data</h2>
<p class="vtr-15__sub">One call site. The type decides which way the step slides.</p>
</header>
<div class="vtr-15__wizard">
<div class="vtr-15__rail" id="vtr-15-rail">
<span class="vtr-15__dot" data-vtr15-dot="0"><span class="vtr-15__pill" id="vtr-15-pill">1</span></span>
<span class="vtr-15__dot" data-vtr15-dot="1"></span>
<span class="vtr-15__dot" data-vtr15-dot="2"></span>
</div>
<div class="vtr-15__step" id="vtr-15-step">
<fieldset class="vtr-15__set">
<legend class="vtr-15__legend" id="vtr-15-legend">Account</legend>
<label class="vtr-15__field"><span id="vtr-15-l1">Work email</span><input class="vtr-15__input" type="email" id="vtr-15-f1" autocomplete="email"></label>
<label class="vtr-15__field"><span id="vtr-15-l2">Team name</span><input class="vtr-15__input" type="text" id="vtr-15-f2"></label>
</fieldset>
</div>
<div class="vtr-15__nav">
<button class="vtr-15__btn" type="button" id="vtr-15-prev" disabled>Back</button>
<button class="vtr-15__btn vtr-15__btn--go" type="button" id="vtr-15-next">Next</button>
</div>
</div>
</div>
</section><section class="vtr-15" aria-labelledby="vtr-15-heading">
<fieldset class="vtr-15__theme">
<legend class="vtr-15__themelegend">Theme</legend>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-auto" checked>
<label class="vtr-15__themeopt" for="vtr-15-theme-auto">Auto</label>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-light">
<label class="vtr-15__themeopt" for="vtr-15-theme-light">Light</label>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-dark">
<label class="vtr-15__themeopt" for="vtr-15-theme-dark">Dark</label>
</fieldset>
<div class="vtr-15__stage">
<header class="vtr-15__head">
<p class="vtr-15__kicker">Step mechanism</p>
<h2 class="vtr-15__heading" id="vtr-15-heading">Direction as data</h2>
<p class="vtr-15__sub">One call site. The type decides which way the step slides.</p>
</header>
<div class="vtr-15__wizard">
<div class="vtr-15__rail" id="vtr-15-rail">
<span class="vtr-15__dot" data-vtr15-dot="0"><span class="vtr-15__pill" id="vtr-15-pill">1</span></span>
<span class="vtr-15__dot" data-vtr15-dot="1"></span>
<span class="vtr-15__dot" data-vtr15-dot="2"></span>
</div>
<div class="vtr-15__step" id="vtr-15-step">
<fieldset class="vtr-15__set">
<legend class="vtr-15__legend" id="vtr-15-legend">Account</legend>
<label class="vtr-15__field"><span id="vtr-15-l1">Work email</span><input class="vtr-15__input" type="email" id="vtr-15-f1" autocomplete="email"></label>
<label class="vtr-15__field"><span id="vtr-15-l2">Team name</span><input class="vtr-15__input" type="text" id="vtr-15-f2"></label>
</fieldset>
</div>
<div class="vtr-15__nav">
<button class="vtr-15__btn" type="button" id="vtr-15-prev" disabled>Back</button>
<button class="vtr-15__btn vtr-15__btn--go" type="button" id="vtr-15-next">Next</button>
</div>
</div>
</div>
</section>.vtr-15 {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
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-15 *,
.vtr-15 *::before,
.vtr-15 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-15 {
--bg: oklch(97% 0.004 250);
--line: oklch(91% 0.008 250);
--ink: oklch(19% 0.012 250);
--muted: oklch(50% 0.014 250);
--accent: oklch(52% 0.13 150);
--accent-soft: oklch(95% 0.03 150);
}
}
@media (prefers-color-scheme: dark) {
.vtr-15 {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
}
.vtr-15[data-theme="light"] {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
}
.vtr-15[data-theme="dark"] {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
.vtr-15__stage {
max-width: 34rem;
margin: 0 auto;
padding: clamp(1.5rem, 5vw, 3.5rem) clamp(1rem, 4vw, 2rem);
display: grid;
gap: 1.25rem;
}
.vtr-15__kicker {
margin: 0 0 0.3rem;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-15__heading {
margin: 0;
font-size: clamp(1.3rem, 4.2vw, 1.75rem);
font-weight: 650;
letter-spacing: -0.025em;
}
.vtr-15__sub {
margin: 0.3rem 0 0;
font-size: 0.86rem;
color: var(--muted);
}
.vtr-15__wizard {
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--surface);
overflow: hidden;
min-inline-size: 0;
}
.vtr-15__rail {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line);
}
.vtr-15__dot {
position: relative;
flex: 1;
height: 0.35rem;
border-radius: 999px;
background: var(--line);
display: grid;
place-items: center;
min-inline-size: 0;
}
.vtr-15__pill {
position: absolute;
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
background: var(--accent);
color: #ffffff;
font-size: 0.72rem;
font-weight: 700;
view-transition-name: vtr-15-pill;
}
.vtr-15__step {
padding: 1.1rem 0.9rem 1.25rem;
view-transition-name: vtr-15-step;
}
.vtr-15__set {
margin: 0;
padding: 0;
border: 0;
display: grid;
gap: 0.8rem;
}
.vtr-15__legend {
padding: 0;
font-size: 0.95rem;
font-weight: 650;
}
.vtr-15__field {
display: grid;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--muted);
min-inline-size: 0;
}
.vtr-15__input {
min-height: 44px;
padding: 0 0.7rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.88rem;
width: 100%;
}
.vtr-15__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.vtr-15__nav {
display: flex;
gap: 0.5rem;
justify-content: space-between;
padding: 0.7rem 0.9rem;
border-top: 1px solid var(--line);
}
.vtr-15__btn {
min-height: 44px;
padding: 0 1.15rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: transparent;
color: var(--ink);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.vtr-15__btn--go {
border-color: var(--accent);
background: var(--accent);
color: #ffffff;
}
.vtr-15__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vtr-15__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@keyframes vtr-15-out-l {
to {
transform: translateX(-8%);
opacity: 0;
}
}
@keyframes vtr-15-in-r {
from {
transform: translateX(8%);
opacity: 0;
}
}
@keyframes vtr-15-out-r {
to {
transform: translateX(8%);
opacity: 0;
}
}
@keyframes vtr-15-in-l {
from {
transform: translateX(-8%);
opacity: 0;
}
}
:root:active-view-transition-type(vtr-15-next) ::view-transition-old(vtr-15-step) {
animation: vtr-15-out-l 300ms ease both;
}
:root:active-view-transition-type(vtr-15-next) ::view-transition-new(vtr-15-step) {
animation: vtr-15-in-r 300ms ease both;
}
:root:active-view-transition-type(vtr-15-prev) ::view-transition-old(vtr-15-step) {
animation: vtr-15-out-r 300ms ease both;
}
:root:active-view-transition-type(vtr-15-prev) ::view-transition-new(vtr-15-step) {
animation: vtr-15-in-l 300ms ease both;
}
::view-transition-group(vtr-15-pill) {
animation-duration: 320ms;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-15-step),
::view-transition-group(vtr-15-pill) {
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-15 {
position: relative;
}
.vtr-15__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-15__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-15__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-15__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-15__themeopt:hover {
opacity: 0.85;
}
.vtr-15__themeinput:checked + .vtr-15__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-15__themeinput:focus-visible + .vtr-15__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-15:has(#vtr-15-theme-light:checked) {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
}
.vtr-15:has(#vtr-15-theme-dark:checked) {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}.vtr-15 {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
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-15 *,
.vtr-15 *::before,
.vtr-15 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-15 {
--bg: oklch(97% 0.004 250);
--line: oklch(91% 0.008 250);
--ink: oklch(19% 0.012 250);
--muted: oklch(50% 0.014 250);
--accent: oklch(52% 0.13 150);
--accent-soft: oklch(95% 0.03 150);
}
}
@media (prefers-color-scheme: dark) {
.vtr-15 {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
}
.vtr-15[data-theme="light"] {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
}
.vtr-15[data-theme="dark"] {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
.vtr-15__stage {
max-width: 34rem;
margin: 0 auto;
padding: clamp(1.5rem, 5vw, 3.5rem) clamp(1rem, 4vw, 2rem);
display: grid;
gap: 1.25rem;
}
.vtr-15__kicker {
margin: 0 0 0.3rem;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-15__heading {
margin: 0;
font-size: clamp(1.3rem, 4.2vw, 1.75rem);
font-weight: 650;
letter-spacing: -0.025em;
}
.vtr-15__sub {
margin: 0.3rem 0 0;
font-size: 0.86rem;
color: var(--muted);
}
.vtr-15__wizard {
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--surface);
overflow: hidden;
min-inline-size: 0;
}
.vtr-15__rail {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line);
}
.vtr-15__dot {
position: relative;
flex: 1;
height: 0.35rem;
border-radius: 999px;
background: var(--line);
display: grid;
place-items: center;
min-inline-size: 0;
}
.vtr-15__pill {
position: absolute;
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
background: var(--accent);
color: #ffffff;
font-size: 0.72rem;
font-weight: 700;
view-transition-name: vtr-15-pill;
}
.vtr-15__step {
padding: 1.1rem 0.9rem 1.25rem;
view-transition-name: vtr-15-step;
}
.vtr-15__set {
margin: 0;
padding: 0;
border: 0;
display: grid;
gap: 0.8rem;
}
.vtr-15__legend {
padding: 0;
font-size: 0.95rem;
font-weight: 650;
}
.vtr-15__field {
display: grid;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--muted);
min-inline-size: 0;
}
.vtr-15__input {
min-height: 44px;
padding: 0 0.7rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.88rem;
width: 100%;
}
.vtr-15__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.vtr-15__nav {
display: flex;
gap: 0.5rem;
justify-content: space-between;
padding: 0.7rem 0.9rem;
border-top: 1px solid var(--line);
}
.vtr-15__btn {
min-height: 44px;
padding: 0 1.15rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: transparent;
color: var(--ink);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.vtr-15__btn--go {
border-color: var(--accent);
background: var(--accent);
color: #ffffff;
}
.vtr-15__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vtr-15__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@keyframes vtr-15-out-l {
to {
transform: translateX(-8%);
opacity: 0;
}
}
@keyframes vtr-15-in-r {
from {
transform: translateX(8%);
opacity: 0;
}
}
@keyframes vtr-15-out-r {
to {
transform: translateX(8%);
opacity: 0;
}
}
@keyframes vtr-15-in-l {
from {
transform: translateX(-8%);
opacity: 0;
}
}
:root:active-view-transition-type(vtr-15-next) ::view-transition-old(vtr-15-step) {
animation: vtr-15-out-l 300ms ease both;
}
:root:active-view-transition-type(vtr-15-next) ::view-transition-new(vtr-15-step) {
animation: vtr-15-in-r 300ms ease both;
}
:root:active-view-transition-type(vtr-15-prev) ::view-transition-old(vtr-15-step) {
animation: vtr-15-out-r 300ms ease both;
}
:root:active-view-transition-type(vtr-15-prev) ::view-transition-new(vtr-15-step) {
animation: vtr-15-in-l 300ms ease both;
}
::view-transition-group(vtr-15-pill) {
animation-duration: 320ms;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-15-step),
::view-transition-group(vtr-15-pill) {
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-15 {
position: relative;
}
.vtr-15__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-15__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-15__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-15__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-15__themeopt:hover {
opacity: 0.85;
}
.vtr-15__themeinput:checked + .vtr-15__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-15__themeinput:focus-visible + .vtr-15__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-15:has(#vtr-15-theme-light:checked) {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
}
.vtr-15:has(#vtr-15-theme-dark:checked) {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}(() => {
const root = document.querySelector('.vtr-15');
if (!root || root.dataset.vtr15) return;
root.dataset.vtr15 = 'on';
const pill = root.querySelector('#vtr-15-pill'), prev = root.querySelector('#vtr-15-prev'), next = root.querySelector('#vtr-15-next');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const slots = ['#vtr-15-legend', '#vtr-15-l1', '#vtr-15-l2'];
const steps = [['Account', 'Work email', 'Team name'], ['Billing', 'Card number', 'Billing postcode'], ['Review', 'Purchase order (optional)', 'Notes for finance']];
let i = 0, busy = false;
const apply = (n) => {
i = n;
slots.forEach((sel, k) => { root.querySelector(sel).textContent = steps[n][k]; });
root.querySelector('[data-vtr15-dot="' + n + '"]').append(pill);
pill.textContent = String(n + 1);
prev.disabled = n === 0; next.textContent = n === steps.length - 1 ? 'Finish' : 'Next';
};
const go = async (d) => {
const n = i + d;
if (n < 0 || n >= steps.length || busy) return;
if (!document.startViewTransition || reduce.matches) apply(n);
else {
busy = true;
try { await document.startViewTransition({ update: () => apply(n), types: ['vtr-15-' + (d > 0 ? 'next' : 'prev')] }).finished; } finally { busy = false; }
}
root.querySelector('#vtr-15-f1').focus();
};
next.addEventListener('click', () => go(1));
prev.addEventListener('click', () => go(-1));
root.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.matches('.vtr-15__input')) { e.preventDefault(); go(1); } });
})();(() => {
const root = document.querySelector('.vtr-15');
if (!root || root.dataset.vtr15) return;
root.dataset.vtr15 = 'on';
const pill = root.querySelector('#vtr-15-pill'), prev = root.querySelector('#vtr-15-prev'), next = root.querySelector('#vtr-15-next');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const slots = ['#vtr-15-legend', '#vtr-15-l1', '#vtr-15-l2'];
const steps = [['Account', 'Work email', 'Team name'], ['Billing', 'Card number', 'Billing postcode'], ['Review', 'Purchase order (optional)', 'Notes for finance']];
let i = 0, busy = false;
const apply = (n) => {
i = n;
slots.forEach((sel, k) => { root.querySelector(sel).textContent = steps[n][k]; });
root.querySelector('[data-vtr15-dot="' + n + '"]').append(pill);
pill.textContent = String(n + 1);
prev.disabled = n === 0; next.textContent = n === steps.length - 1 ? 'Finish' : 'Next';
};
const go = async (d) => {
const n = i + d;
if (n < 0 || n >= steps.length || busy) return;
if (!document.startViewTransition || reduce.matches) apply(n);
else {
busy = true;
try { await document.startViewTransition({ update: () => apply(n), types: ['vtr-15-' + (d > 0 ? 'next' : 'prev')] }).finished; } finally { busy = false; }
}
root.querySelector('#vtr-15-f1').focus();
};
next.addEventListener('click', () => go(1));
prev.addEventListener('click', () => go(-1));
root.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.matches('.vtr-15__input')) { e.preventDefault(); go(1); } });
})();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: Multi Step Form Wizard
Source: https://codefronts.com/motion/css-view-transitions/multi-step-form-wizard/
Step two slides in from the right on Next and from the left on Back, with the step indicator travelling as its own named element. The form itself is deliberately plain — two fields per step — because the subject is the step-change mechanism and the direction-aware branch, not the form design.
## HTML
```html
<section class="vtr-15" aria-labelledby="vtr-15-heading">
<fieldset class="vtr-15__theme">
<legend class="vtr-15__themelegend">Theme</legend>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-auto" checked>
<label class="vtr-15__themeopt" for="vtr-15-theme-auto">Auto</label>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-light">
<label class="vtr-15__themeopt" for="vtr-15-theme-light">Light</label>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-dark">
<label class="vtr-15__themeopt" for="vtr-15-theme-dark">Dark</label>
</fieldset>
<div class="vtr-15__stage">
<header class="vtr-15__head">
<p class="vtr-15__kicker">Step mechanism</p>
<h2 class="vtr-15__heading" id="vtr-15-heading">Direction as data</h2>
<p class="vtr-15__sub">One call site. The type decides which way the step slides.</p>
</header>
<div class="vtr-15__wizard">
<div class="vtr-15__rail" id="vtr-15-rail">
<span class="vtr-15__dot" data-vtr15-dot="0"><span class="vtr-15__pill" id="vtr-15-pill">1</span></span>
<span class="vtr-15__dot" data-vtr15-dot="1"></span>
<span class="vtr-15__dot" data-vtr15-dot="2"></span>
</div>
<div class="vtr-15__step" id="vtr-15-step">
<fieldset class="vtr-15__set">
<legend class="vtr-15__legend" id="vtr-15-legend">Account</legend>
<label class="vtr-15__field"><span id="vtr-15-l1">Work email</span><input class="vtr-15__input" type="email" id="vtr-15-f1" autocomplete="email"></label>
<label class="vtr-15__field"><span id="vtr-15-l2">Team name</span><input class="vtr-15__input" type="text" id="vtr-15-f2"></label>
</fieldset>
</div>
<div class="vtr-15__nav">
<button class="vtr-15__btn" type="button" id="vtr-15-prev" disabled>Back</button>
<button class="vtr-15__btn vtr-15__btn--go" type="button" id="vtr-15-next">Next</button>
</div>
</div>
</div>
</section>
```
## CSS
```css
.vtr-15 {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
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-15 *,
.vtr-15 *::before,
.vtr-15 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-15 {
--bg: oklch(97% 0.004 250);
--line: oklch(91% 0.008 250);
--ink: oklch(19% 0.012 250);
--muted: oklch(50% 0.014 250);
--accent: oklch(52% 0.13 150);
--accent-soft: oklch(95% 0.03 150);
}
}
@media (prefers-color-scheme: dark) {
.vtr-15 {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
}
.vtr-15[data-theme="light"] {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
}
.vtr-15[data-theme="dark"] {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
.vtr-15__stage {
max-width: 34rem;
margin: 0 auto;
padding: clamp(1.5rem, 5vw, 3.5rem) clamp(1rem, 4vw, 2rem);
display: grid;
gap: 1.25rem;
}
.vtr-15__kicker {
margin: 0 0 0.3rem;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-15__heading {
margin: 0;
font-size: clamp(1.3rem, 4.2vw, 1.75rem);
font-weight: 650;
letter-spacing: -0.025em;
}
.vtr-15__sub {
margin: 0.3rem 0 0;
font-size: 0.86rem;
color: var(--muted);
}
.vtr-15__wizard {
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--surface);
overflow: hidden;
min-inline-size: 0;
}
.vtr-15__rail {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line);
}
.vtr-15__dot {
position: relative;
flex: 1;
height: 0.35rem;
border-radius: 999px;
background: var(--line);
display: grid;
place-items: center;
min-inline-size: 0;
}
.vtr-15__pill {
position: absolute;
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
background: var(--accent);
color: #ffffff;
font-size: 0.72rem;
font-weight: 700;
view-transition-name: vtr-15-pill;
}
.vtr-15__step {
padding: 1.1rem 0.9rem 1.25rem;
view-transition-name: vtr-15-step;
}
.vtr-15__set {
margin: 0;
padding: 0;
border: 0;
display: grid;
gap: 0.8rem;
}
.vtr-15__legend {
padding: 0;
font-size: 0.95rem;
font-weight: 650;
}
.vtr-15__field {
display: grid;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--muted);
min-inline-size: 0;
}
.vtr-15__input {
min-height: 44px;
padding: 0 0.7rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.88rem;
width: 100%;
}
.vtr-15__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.vtr-15__nav {
display: flex;
gap: 0.5rem;
justify-content: space-between;
padding: 0.7rem 0.9rem;
border-top: 1px solid var(--line);
}
.vtr-15__btn {
min-height: 44px;
padding: 0 1.15rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: transparent;
color: var(--ink);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.vtr-15__btn--go {
border-color: var(--accent);
background: var(--accent);
color: #ffffff;
}
.vtr-15__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vtr-15__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@keyframes vtr-15-out-l {
to {
transform: translateX(-8%);
opacity: 0;
}
}
@keyframes vtr-15-in-r {
from {
transform: translateX(8%);
opacity: 0;
}
}
@keyframes vtr-15-out-r {
to {
transform: translateX(8%);
opacity: 0;
}
}
@keyframes vtr-15-in-l {
from {
transform: translateX(-8%);
opacity: 0;
}
}
:root:active-view-transition-type(vtr-15-next) ::view-transition-old(vtr-15-step) {
animation: vtr-15-out-l 300ms ease both;
}
:root:active-view-transition-type(vtr-15-next) ::view-transition-new(vtr-15-step) {
animation: vtr-15-in-r 300ms ease both;
}
:root:active-view-transition-type(vtr-15-prev) ::view-transition-old(vtr-15-step) {
animation: vtr-15-out-r 300ms ease both;
}
:root:active-view-transition-type(vtr-15-prev) ::view-transition-new(vtr-15-step) {
animation: vtr-15-in-l 300ms ease both;
}
::view-transition-group(vtr-15-pill) {
animation-duration: 320ms;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-15-step),
::view-transition-group(vtr-15-pill) {
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-15 {
position: relative;
}
.vtr-15__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-15__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-15__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-15__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-15__themeopt:hover {
opacity: 0.85;
}
.vtr-15__themeinput:checked + .vtr-15__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-15__themeinput:focus-visible + .vtr-15__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-15:has(#vtr-15-theme-light:checked) {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
}
.vtr-15:has(#vtr-15-theme-dark:checked) {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.vtr-15');
if (!root || root.dataset.vtr15) return;
root.dataset.vtr15 = 'on';
const pill = root.querySelector('#vtr-15-pill'), prev = root.querySelector('#vtr-15-prev'), next = root.querySelector('#vtr-15-next');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const slots = ['#vtr-15-legend', '#vtr-15-l1', '#vtr-15-l2'];
const steps = [['Account', 'Work email', 'Team name'], ['Billing', 'Card number', 'Billing postcode'], ['Review', 'Purchase order (optional)', 'Notes for finance']];
let i = 0, busy = false;
const apply = (n) => {
i = n;
slots.forEach((sel, k) => { root.querySelector(sel).textContent = steps[n][k]; });
root.querySelector('[data-vtr15-dot="' + n + '"]').append(pill);
pill.textContent = String(n + 1);
prev.disabled = n === 0; next.textContent = n === steps.length - 1 ? 'Finish' : 'Next';
};
const go = async (d) => {
const n = i + d;
if (n < 0 || n >= steps.length || busy) return;
if (!document.startViewTransition || reduce.matches) apply(n);
else {
busy = true;
try { await document.startViewTransition({ update: () => apply(n), types: ['vtr-15-' + (d > 0 ? 'next' : 'prev')] }).finished; } finally { busy = false; }
}
root.querySelector('#vtr-15-f1').focus();
};
next.addEventListener('click', () => go(1));
prev.addEventListener('click', () => go(-1));
root.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.matches('.vtr-15__input')) { e.preventDefault(); go(1); } });
})();
```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: Multi Step Form Wizard
Source: https://codefronts.com/motion/css-view-transitions/multi-step-form-wizard/
Step two slides in from the right on Next and from the left on Back, with the step indicator travelling as its own named element. The form itself is deliberately plain — two fields per step — because the subject is the step-change mechanism and the direction-aware branch, not the form design.
## HTML
```html
<section class="vtr-15" aria-labelledby="vtr-15-heading">
<fieldset class="vtr-15__theme">
<legend class="vtr-15__themelegend">Theme</legend>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-auto" checked>
<label class="vtr-15__themeopt" for="vtr-15-theme-auto">Auto</label>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-light">
<label class="vtr-15__themeopt" for="vtr-15-theme-light">Light</label>
<input class="vtr-15__themeinput" type="radio" name="vtr-15-theme" id="vtr-15-theme-dark">
<label class="vtr-15__themeopt" for="vtr-15-theme-dark">Dark</label>
</fieldset>
<div class="vtr-15__stage">
<header class="vtr-15__head">
<p class="vtr-15__kicker">Step mechanism</p>
<h2 class="vtr-15__heading" id="vtr-15-heading">Direction as data</h2>
<p class="vtr-15__sub">One call site. The type decides which way the step slides.</p>
</header>
<div class="vtr-15__wizard">
<div class="vtr-15__rail" id="vtr-15-rail">
<span class="vtr-15__dot" data-vtr15-dot="0"><span class="vtr-15__pill" id="vtr-15-pill">1</span></span>
<span class="vtr-15__dot" data-vtr15-dot="1"></span>
<span class="vtr-15__dot" data-vtr15-dot="2"></span>
</div>
<div class="vtr-15__step" id="vtr-15-step">
<fieldset class="vtr-15__set">
<legend class="vtr-15__legend" id="vtr-15-legend">Account</legend>
<label class="vtr-15__field"><span id="vtr-15-l1">Work email</span><input class="vtr-15__input" type="email" id="vtr-15-f1" autocomplete="email"></label>
<label class="vtr-15__field"><span id="vtr-15-l2">Team name</span><input class="vtr-15__input" type="text" id="vtr-15-f2"></label>
</fieldset>
</div>
<div class="vtr-15__nav">
<button class="vtr-15__btn" type="button" id="vtr-15-prev" disabled>Back</button>
<button class="vtr-15__btn vtr-15__btn--go" type="button" id="vtr-15-next">Next</button>
</div>
</div>
</div>
</section>
```
## CSS
```css
.vtr-15 {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
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-15 *,
.vtr-15 *::before,
.vtr-15 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-15 {
--bg: oklch(97% 0.004 250);
--line: oklch(91% 0.008 250);
--ink: oklch(19% 0.012 250);
--muted: oklch(50% 0.014 250);
--accent: oklch(52% 0.13 150);
--accent-soft: oklch(95% 0.03 150);
}
}
@media (prefers-color-scheme: dark) {
.vtr-15 {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
}
.vtr-15[data-theme="light"] {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
}
.vtr-15[data-theme="dark"] {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
.vtr-15__stage {
max-width: 34rem;
margin: 0 auto;
padding: clamp(1.5rem, 5vw, 3.5rem) clamp(1rem, 4vw, 2rem);
display: grid;
gap: 1.25rem;
}
.vtr-15__kicker {
margin: 0 0 0.3rem;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-15__heading {
margin: 0;
font-size: clamp(1.3rem, 4.2vw, 1.75rem);
font-weight: 650;
letter-spacing: -0.025em;
}
.vtr-15__sub {
margin: 0.3rem 0 0;
font-size: 0.86rem;
color: var(--muted);
}
.vtr-15__wizard {
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--surface);
overflow: hidden;
min-inline-size: 0;
}
.vtr-15__rail {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line);
}
.vtr-15__dot {
position: relative;
flex: 1;
height: 0.35rem;
border-radius: 999px;
background: var(--line);
display: grid;
place-items: center;
min-inline-size: 0;
}
.vtr-15__pill {
position: absolute;
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
background: var(--accent);
color: #ffffff;
font-size: 0.72rem;
font-weight: 700;
view-transition-name: vtr-15-pill;
}
.vtr-15__step {
padding: 1.1rem 0.9rem 1.25rem;
view-transition-name: vtr-15-step;
}
.vtr-15__set {
margin: 0;
padding: 0;
border: 0;
display: grid;
gap: 0.8rem;
}
.vtr-15__legend {
padding: 0;
font-size: 0.95rem;
font-weight: 650;
}
.vtr-15__field {
display: grid;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--muted);
min-inline-size: 0;
}
.vtr-15__input {
min-height: 44px;
padding: 0 0.7rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.88rem;
width: 100%;
}
.vtr-15__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.vtr-15__nav {
display: flex;
gap: 0.5rem;
justify-content: space-between;
padding: 0.7rem 0.9rem;
border-top: 1px solid var(--line);
}
.vtr-15__btn {
min-height: 44px;
padding: 0 1.15rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: transparent;
color: var(--ink);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.vtr-15__btn--go {
border-color: var(--accent);
background: var(--accent);
color: #ffffff;
}
.vtr-15__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vtr-15__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@keyframes vtr-15-out-l {
to {
transform: translateX(-8%);
opacity: 0;
}
}
@keyframes vtr-15-in-r {
from {
transform: translateX(8%);
opacity: 0;
}
}
@keyframes vtr-15-out-r {
to {
transform: translateX(8%);
opacity: 0;
}
}
@keyframes vtr-15-in-l {
from {
transform: translateX(-8%);
opacity: 0;
}
}
:root:active-view-transition-type(vtr-15-next) ::view-transition-old(vtr-15-step) {
animation: vtr-15-out-l 300ms ease both;
}
:root:active-view-transition-type(vtr-15-next) ::view-transition-new(vtr-15-step) {
animation: vtr-15-in-r 300ms ease both;
}
:root:active-view-transition-type(vtr-15-prev) ::view-transition-old(vtr-15-step) {
animation: vtr-15-out-r 300ms ease both;
}
:root:active-view-transition-type(vtr-15-prev) ::view-transition-new(vtr-15-step) {
animation: vtr-15-in-l 300ms ease both;
}
::view-transition-group(vtr-15-pill) {
animation-duration: 320ms;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-15-step),
::view-transition-group(vtr-15-pill) {
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-15 {
position: relative;
}
.vtr-15__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-15__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-15__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-15__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-15__themeopt:hover {
opacity: 0.85;
}
.vtr-15__themeinput:checked + .vtr-15__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-15__themeinput:focus-visible + .vtr-15__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-15:has(#vtr-15-theme-light:checked) {
--bg: #f6f7f9;
--surface: #ffffff;
--line: #e2e6ec;
--ink: #12161c;
--muted: #5c6673;
--accent: #15803d;
--accent-soft: #e6f4ea;
}
.vtr-15:has(#vtr-15-theme-dark:checked) {
--bg: #0e1114;
--surface: #16191e;
--line: #262b33;
--ink: #eef1f5;
--muted: #98a2af;
--accent: #4ade80;
--accent-soft: #16281d;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.vtr-15');
if (!root || root.dataset.vtr15) return;
root.dataset.vtr15 = 'on';
const pill = root.querySelector('#vtr-15-pill'), prev = root.querySelector('#vtr-15-prev'), next = root.querySelector('#vtr-15-next');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const slots = ['#vtr-15-legend', '#vtr-15-l1', '#vtr-15-l2'];
const steps = [['Account', 'Work email', 'Team name'], ['Billing', 'Card number', 'Billing postcode'], ['Review', 'Purchase order (optional)', 'Notes for finance']];
let i = 0, busy = false;
const apply = (n) => {
i = n;
slots.forEach((sel, k) => { root.querySelector(sel).textContent = steps[n][k]; });
root.querySelector('[data-vtr15-dot="' + n + '"]').append(pill);
pill.textContent = String(n + 1);
prev.disabled = n === 0; next.textContent = n === steps.length - 1 ? 'Finish' : 'Next';
};
const go = async (d) => {
const n = i + d;
if (n < 0 || n >= steps.length || busy) return;
if (!document.startViewTransition || reduce.matches) apply(n);
else {
busy = true;
try { await document.startViewTransition({ update: () => apply(n), types: ['vtr-15-' + (d > 0 ? 'next' : 'prev')] }).finished; } finally { busy = false; }
}
root.querySelector('#vtr-15-f1').focus();
};
next.addEventListener('click', () => go(1));
prev.addEventListener('click', () => go(-1));
root.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.matches('.vtr-15__input')) { e.preventDefault(); go(1); } });
})();
```How this works
The direction is data, not a branch in your code. Next and Back both call the same function; the only difference is the type passed to the transition — types: ['vtr-15-next'] or ['vtr-15-prev']. CSS reads that with :active-view-transition-type() and picks the matching keyframes, so the slide direction is a styling decision rather than a JavaScript one.
The indicator is a separate named element. The step pill carries its own name, so it travels along the rail while the fieldset slides. Two names in one transition, coordinated by the browser — the same pattern as demo 11's flying thumbnail and badge.
Focus has to move to the new step. The visual change is complete and the focus position is not: after the swap, focus goes to the first field of the new step. Without it a keyboard user is left on a Next button that now belongs to a step they cannot see, and a screen-reader user hears nothing at all. The step heading is also announced through the fieldset legend, which is why these are real fieldsets rather than divs.
No submit event. These demos run in a sandboxed frame with no form submission allowed, so every control is a <button type="button"> with a click listener plus an Enter-keydown listener on the inputs. That is also a reasonable production pattern for a wizard, where each step advances rather than submits — but if you copy this into a real form, wire the final step to a genuine submit.
Make it yours
- Add a step by extending the steps array; the indicator and the transition need no changes.
- Swap the slide keyframes for a fade-through if the wizard is more of a checklist than a path.
- Add per-step validation before the transition and refuse to advance when it fails.
- Change
--accentto retint the indicator, the primary button and the focus ring. - Persist entered values so Back returns a populated step rather than an empty one.
- Announce the step number through a live region as well as the visible indicator.
Gotchas — read before shipping
form.addEventListener('submit', ...)never fires in a sandboxed frame without form permissions; use button clicks and an Enter handler.- Focus stays on the old step's button unless you move it, because a transition changes nothing about focus.
- Passing a direction as a class on the root instead of a transition type leaves the class attached if the transition is interrupted.
- Naming the fieldset wrapper but not the indicator makes the pill warp inside the sliding snapshot.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 125+ | 18.2+ | not yet | 125+ |
This demo branches on :active-view-transition-type(), which is newer than the base API: Chrome 125 and Safari 18.2, with Firefox not shipping View Transitions at all. On Chrome 111 to 124 the transition still runs but ignores the type, so both directions get the default cross-fade rather than a direction-aware slide — a graceful downgrade, not a break. Behind the feature detect, Firefox changes step instantly with focus moved correctly.