*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
/* ── Theme tokens ── */
[data-theme="dark"] {
--bg: #070a12;
--bg-elevated: #121a2b;
--border: #263249;
--text: #f0f4fb;
--text-secondary: #8a95a8;
--accent: #86e8ff;
--secondary: #ae52ff;
--tertiary: #ff40d6;
--code-bg: rgba(134, 232, 255, 0.1);
--code-color: #86e8ff;
--card-shadow: rgba(0, 0, 0, 0.3);
}
[data-theme="light"] {
--bg: #f5f7fb;
--bg-elevated: #ffffff;
--border: #dce3ed;
--text: #1a1f2e;
--text-secondary: #5a6478;
--accent: #0077cc;
--secondary: #7c3aed;
--tertiary: #db2777;
--code-bg: rgba(0, 119, 204, 0.08);
--code-color: #0077cc;
--card-shadow: rgba(0, 0, 0, 0.06);
}
html {
font-family: 'Inter', 'SF Pro Display', system-ui, sans-serif;
}
body {
background: var(--bg);
color: var(--text);
min-height: 100vh;
transition: none; /* transitions handled by View Transitions API */
}
/* ── Page ── */
.page {
max-width: 880px;
margin: 0 auto;
padding: 5rem 2rem 4rem;
}
.header {
text-align: center;
margin-bottom: 2.5rem;
}
.eyebrow {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
margin-bottom: 1rem;
}
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.subtitle {
font-size: clamp(0.9rem, 2vw, 1.05rem);
color: var(--text-secondary);
max-width: 500px;
margin: 0.75rem auto 0;
line-height: 1.6;
}
/* ── Toggle button ── */
.toggle-area {
display: flex;
justify-content: center;
margin-bottom: 3rem;
}
.theme-toggle {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text);
font: 600 0.85rem/1 'Inter', system-ui, sans-serif;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.theme-toggle:hover {
border-color: var(--accent);
box-shadow: 0 0 16px rgba(0, 0, 0, 0.1);
}
.toggle-icon {
display: flex;
align-items: center;
color: var(--accent);
}
[data-theme="dark"] .icon-moon { display: none; }
[data-theme="light"] .icon-sun { display: none; }
/* ── Cards ── */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
}
.demo-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.5rem;
box-shadow: 0 2px 12px var(--card-shadow);
}
.card-header {
display: flex;
gap: 0.4rem;
margin-bottom: 1rem;
}
.card-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--dot-color);
}
.demo-card h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.demo-card p {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
code {
background: var(--code-bg);
color: var(--code-color);
padding: 0.12rem 0.4rem;
border-radius: 4px;
font-size: 0.8rem;
}
/* ── Sample text ── */
.sample-text {
margin-bottom: 2.5rem;
}
.sample-text h2 {
font-size: clamp(1.5rem, 3vw, 2.2rem);
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 1rem;
}
.sample-text p {
font-size: 0.95rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1rem;
}
/* ── Back button ── */
.btn-back {
display: inline-block;
padding: 0.7rem 2rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--accent);
text-decoration: none;
font: 600 0.85rem/1 'Inter', system-ui, sans-serif;
transition: border-color 0.25s, background 0.25s;
}
.btn-back:hover {
background: var(--bg-elevated);
border-color: var(--accent);
}
/* ══════════════════════════════════════════
View Transition: Circular clip-path wipe
══════════════════════════════════════════ */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* Old snapshot fades, new snapshot clips in */
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 9999;
animation: clip-reveal 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes clip-reveal {
from {
clip-path: circle(0% at var(--click-x, 50%) var(--click-y, 50%));
}
to {
clip-path: circle(150% at var(--click-x, 50%) var(--click-y, 50%));
}
}
/* ── Fallback for no View Transitions ── */
.no-vt body {
transition: background 0.3s ease, color 0.3s ease;
}
@media (max-width: 640px) {
.page { padding: 3rem 1rem 3rem; }
.card-grid { grid-template-columns: 1fr; }
}
if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
// ── Demo shell ──
initDemoShell({
title: 'Theme Transition',
category: 'transitions',
tech: ['view-transitions-api', 'clip-path'],
});
// ── Check support ──
const supportsVT = typeof document.startViewTransition === 'function';
if (!supportsVT) document.body.classList.add('no-vt');
// ── Refs ──
const root = document.documentElement;
const toggle = document.getElementById('theme-toggle');
const label = document.getElementById('toggle-label');
function getCurrentTheme() {
return root.getAttribute('data-theme') || 'dark';
}
function updateLabel() {
const theme = getCurrentTheme();
label.textContent = theme === 'dark' ? 'Switch to Light' : 'Switch to Dark';
}
// ── Toggle handler ──
toggle.addEventListener('click', (e) => {
const nextTheme = getCurrentTheme() === 'dark' ? 'light' : 'dark';
// Set CSS custom properties for click position (used in clip-path animation)
const rect = toggle.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
root.style.setProperty('--click-x', `${x}px`);
root.style.setProperty('--click-y', `${y}px`);
const applyTheme = () => {
root.setAttribute('data-theme', nextTheme);
updateLabel();
};
if (supportsVT && !prefersReducedMotion()) {
const transition = document.startViewTransition(applyTheme);
} else {
applyTheme();
}
});
// ── Init ──
updateLabel();
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Theme Transition — stealthisdesign</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="page">
<header class="header">
<span class="eyebrow">Demo 13</span>
<h1>Theme Transition</h1>
<p class="subtitle">Click the toggle below to switch themes with a circular clip-path wipe powered by the View Transitions API.</p>
</header>
<div class="toggle-area">
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
<span class="toggle-icon" id="toggle-icon">
<svg class="icon-sun" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg class="icon-moon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</span>
<span class="toggle-label" id="toggle-label">Switch to Light</span>
</button>
</div>
<section class="demo-content">
<div class="card-grid">
<div class="demo-card">
<div class="card-header">
<span class="card-dot" style="--dot-color: var(--accent);"></span>
<span class="card-dot" style="--dot-color: var(--secondary);"></span>
<span class="card-dot" style="--dot-color: var(--tertiary);"></span>
</div>
<h3>View Transitions API</h3>
<p>The <code>document.startViewTransition()</code> method captures before/after snapshots and animates between them using CSS pseudo-elements.</p>
</div>
<div class="demo-card">
<div class="card-header">
<span class="card-dot" style="--dot-color: var(--accent);"></span>
<span class="card-dot" style="--dot-color: var(--secondary);"></span>
<span class="card-dot" style="--dot-color: var(--tertiary);"></span>
</div>
<h3>Circular Wipe</h3>
<p>A custom <code>::view-transition-new(root)</code> animation uses <code>clip-path: circle()</code> expanding from the toggle button position.</p>
</div>
<div class="demo-card">
<div class="card-header">
<span class="card-dot" style="--dot-color: var(--accent);"></span>
<span class="card-dot" style="--dot-color: var(--secondary);"></span>
<span class="card-dot" style="--dot-color: var(--tertiary);"></span>
</div>
<h3>CSS Custom Properties</h3>
<p>Theme colors are defined as CSS variables on <code>[data-theme]</code>. The transition swaps theme by toggling this attribute.</p>
</div>
</div>
<div class="sample-text">
<h2>The Art of Transitions</h2>
<p>A well-crafted theme transition doesn't just swap colors — it tells a story. The circular wipe radiates from the point of interaction, creating a clear cause-and-effect relationship that feels natural and intentional.</p>
<p>Without the View Transitions API, achieving this effect would require complex overlay management, clipping containers, and careful z-index choreography. With it, the browser handles the heavy lifting.</p>
</div>
</section>
<a href="/" class="btn-back">Back to Showcase</a>
</main>
<script src="script.js"></script>
</body>
</html>