A still — with scripts on, it runs right here The demo itself — scroll inside the frame
A still — with scripts on, it runs right here The demo itself — scroll inside the frame
Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.
Everything in this panel, including the contents behind each URL, in one Markdown file — Large, so it goes as a file (copy is for 32 KB and under)
Before/after wipe — Before/after wipe Stack two CSS-drawn images on the screen: the "before" image underneath and the "after" image on top. A divider line and its handle travel from left to right as the user scrolls through a 300vh section. The top image uses `clip-path: inset(0 calc((1 - var(--p)) * 100%) 0 0)` to show only the left of the divider, so at --p 0 the before image fills the frame and at --p 1 the after image does. Hold the frame in a 100vh position: sticky box, put the "after" tag at the left edge of the top image and the "before" tag at the right edge of the bottom image. Implement with CSS and plain JavaScript only; do not use a library. Do not drop: - The top image shows only the left of the divider and swaps by clipping, not scaling - The divider moves inside the pinned section; the frame stays fixed on screen - With reduced motion, show the after image in full and stop A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-compare-wipe/demo.html
/designs/motion-compare-wipe/demo.html — One self-contained HTML file. CSS and plain JavaScript only; it fetches nothing.
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>30 ビフォーアフター — Before/after wipe</title>
<style>
/* dv-motion-base:start */
:root {
--ground: #FFFFFF;
--surface: #F3F5F8;
--ink: #14181F;
--muted: #5A6472;
--accent: #1F5FA8;
--rule: #D8DDE4;
--accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
--accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
--shade: color-mix(in srgb, var(--ink) 14%, transparent);
--font: "Zen Kaku Gothic New", "Hiragino Sans", "Noto Sans JP", "Yu Gothic", system-ui, sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--t-label: 13px;
--t-body: 16px;
--t-lead: 20px;
--t-h2: 32px;
--t-h1: clamp(40px, 7vw, 72px);
--s1: 4px;
--s2: 8px;
--s3: 16px;
--s4: 24px;
--s5: 40px;
--s6: 64px;
--s7: 104px;
--radius: 12px;
--ease: cubic-bezier(.2, .7, .2, 1);
--dur: .7s;
--measure: 1080px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: var(--font);
font-size: var(--t-body);
line-height: 1.7;
font-feature-settings: "palt";
overflow-x: hidden;
}
h1, h2, h3, p, ul, ol, figure { margin: 0; padding: 0; }
ul, ol { list-style: none; }
.dv-wrap { width: min(100% - 2 * var(--s4), var(--measure)); margin-inline: auto; }
.dv-label {
min-height: 72vh;
display: grid;
align-content: center;
gap: var(--s3);
padding-block: var(--s6);
border-bottom: 1px solid var(--rule);
}
.dv-num { font-family: var(--mono); font-size: var(--t-lead); font-weight: 700; color: var(--accent); letter-spacing: .04em; }
.dv-name { font-size: var(--t-h1); font-weight: 700; line-height: 1.15; letter-spacing: .01em; }
.dv-ai { font-family: var(--mono); font-size: var(--t-lead); color: var(--muted); }
.dv-desc { font-size: var(--t-lead); color: var(--ink); }
.dv-hint { font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; margin-top: var(--s4); }
.dv-stage { padding-block: var(--s7); }
.dv-h2 { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; }
.dv-lead { font-size: var(--t-lead); color: var(--muted); }
.dv-eyebrow { font-family: var(--mono); font-size: var(--t-label); color: var(--accent); letter-spacing: .12em; text-transform: uppercase; }
.dv-card { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); padding: var(--s4); }
.dv-ph {
border-radius: var(--radius);
background: linear-gradient(135deg, var(--accent-soft), var(--accent-mid) 60%, var(--accent));
aspect-ratio: 16 / 9;
}
.dv-ph--alt { background: linear-gradient(200deg, var(--surface), var(--accent-soft) 55%, var(--accent-mid)); }
.dv-ph--ink { background: linear-gradient(160deg, var(--ink), color-mix(in srgb, var(--ink) 55%, var(--accent))); }
.dv-end {
min-height: 60vh;
display: grid;
place-items: center;
border-top: 1px solid var(--rule);
color: var(--muted);
font-family: var(--mono);
font-size: var(--t-label);
letter-spacing: .12em;
}
/* dv-motion-base:end */
/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.stack { display: grid; gap: var(--s6); }
.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease); }
.js [data-reveal].is-in { opacity: 1; transform: none; }
/* 区画の高さ 300vh。中の箱は 100vh で画面に留まり、--p で仕切りを動かす */
.cmp { height: 300vh; }
.cmp-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; overflow: hidden; }
.cmp-frame {
position: relative;
width: min(100vw - 2 * var(--s5), 960px);
aspect-ratio: 16 / 9;
box-shadow: 0 24px 60px -24px var(--shade);
border-radius: var(--radius);
}
.cmp-fig { position: absolute; inset: 0; margin: 0; border-radius: var(--radius); overflow: hidden; }
.cmp-fig--before { background: var(--surface); }
.cmp-fig--after {
background: linear-gradient(135deg, var(--accent-soft), var(--accent-mid) 60%, var(--accent));
clip-path: inset(0 calc((1 - var(--p)) * 100%) 0 0);
}
.cmp-art { position: absolute; inset: 0; padding: var(--s6) var(--s5) var(--s5); display: grid; gap: var(--s3); align-content: start; }
.cmp-bar { height: 14px; border-radius: 999px; background: var(--rule); }
.cmp-fig--after .cmp-bar { background: color-mix(in srgb, var(--ground) 70%, transparent); }
.cmp-bar:nth-child(2) { width: 62%; }
.cmp-bar:nth-child(3) { width: 80%; }
.cmp-bar:nth-child(4) { width: 46%; }
.cmp-tag {
position: absolute; top: var(--s3);
padding: var(--s1) var(--s3);
border-radius: 999px;
font-size: var(--t-label);
font-weight: 700;
}
.cmp-tag--before { right: var(--s3); background: var(--ground); color: var(--ink); }
.cmp-tag--after { left: var(--s3); background: var(--ink); color: var(--ground); }
.cmp-track { position: absolute; inset: 0; transform: translateX(calc(var(--p) * 100%)); pointer-events: none; }
.cmp-line { position: absolute; top: 0; bottom: 0; left: -1px; width: 2px; background: var(--ground); }
.cmp-knob {
position: absolute; top: 50%; left: 0;
width: 48px; height: 48px; margin: -24px 0 0 -24px;
border-radius: 50%;
background: var(--ground) linear-gradient(var(--ink), var(--ink)) center / 2px 16px no-repeat;
box-shadow: 0 6px 20px var(--shade);
}
@media (max-width: 600px) {
.cmp-frame { aspect-ratio: 4 / 3; }
}
@media (prefers-reduced-motion: reduce) {
.js [data-reveal] { opacity: 1; transform: none; transition: none; }
.cmp-fig--after { clip-path: none; }
.cmp-track { display: none; }
}
</style>
</head>
<body data-motion-number="30">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">30</p>
<h1 class="dv-name">ビフォーアフター</h1>
<p class="dv-ai">Before/after wipe</p>
<p class="dv-desc">仕切りが動いて前後が入れ替わる</p>
<p class="dv-hint">SCROLL ↓</p>
</div>
</header>
<main class="dv-stage">
<div class="dv-wrap stack">
<section class="dv-card" data-reveal>
<p class="dv-eyebrow">Section 1</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。スクロールすると仕切りが右へ動き、前の図が後の図に入れ替わります。</p>
</section>
</div>
<section class="cmp" data-progress="pin">
<div class="cmp-stick">
<div class="cmp-frame">
<figure class="cmp-fig cmp-fig--before">
<span class="cmp-tag cmp-tag--before">前</span>
<div class="cmp-art">
<div class="cmp-bar"></div>
<div class="cmp-bar"></div>
<div class="cmp-bar"></div>
<div class="cmp-bar"></div>
</div>
</figure>
<figure class="cmp-fig cmp-fig--after">
<span class="cmp-tag cmp-tag--after">後</span>
<div class="cmp-art">
<div class="cmp-bar"></div>
<div class="cmp-bar"></div>
<div class="cmp-bar"></div>
<div class="cmp-bar"></div>
</div>
</figure>
<div class="cmp-track" aria-hidden="true">
<span class="cmp-line"></span>
<span class="cmp-knob"></span>
</div>
</div>
</div>
</section>
<div class="dv-wrap stack">
<section class="dv-card" data-reveal>
<p class="dv-eyebrow">Section 2</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。仕切りが端まで届いたあとに、この段落が続きます。</p>
</section>
</div>
</main>
<footer class="dv-end">END — 30 ビフォーアフター</footer>
<script>
/* dv-motion-helpers:start */
const dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.documentElement.classList.add('js');
/**
* 画面に入った要素へ .is-in を付ける。
* data-reveal="repeat" の要素は画面を出たら外す(何度でも再生)。
*/
function dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {
const els = [...document.querySelectorAll(selector)];
if (dvReduce || !('IntersectionObserver' in window)) {
els.forEach((el) => el.classList.add('is-in'));
return;
}
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('is-in');
if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);
} else if (e.target.dataset.reveal === 'repeat') {
e.target.classList.remove('is-in');
}
}
}, { threshold, rootMargin });
els.forEach((el) => io.observe(el));
}
/**
* スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。
* data-progress="through" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1
* data-progress="pin" : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1
* (中に position: sticky の箱を持つ、背の高い区画用)
* data-progress="page" : ページ全体のスクロール量
* onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。
*/
function dvProgress(onUpdate) {
const els = [...document.querySelectorAll('[data-progress]')];
const clamp = (n) => Math.min(1, Math.max(0, n));
let queued = false;
const measure = () => {
queued = false;
const vh = window.innerHeight;
for (const el of els) {
const r = el.getBoundingClientRect();
let p;
if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;
else if (el.dataset.progress === 'page') {
const max = document.documentElement.scrollHeight - vh;
p = max > 0 ? window.scrollY / max : 0;
} else p = (vh - r.top) / (vh + r.height);
p = clamp(p);
el.style.setProperty('--p', p.toFixed(4));
if (onUpdate) onUpdate(el, p);
}
};
const request = () => {
if (!queued) { queued = true; requestAnimationFrame(measure); }
};
addEventListener('scroll', request, { passive: true });
addEventListener('resize', request);
measure();
}
/* dv-motion-helpers:end */
/* ---- この演出だけの JS ---- */
dvReveal();
dvProgress();
</script>
</body>
</html>