一式
空気感と補助 40–44 — 読み心地を整える演出
ベロシティスキューから追従 CTA まで 5 種。主役にはならないが、ページの手触りと導線を整える動き
一式
ベロシティスキューから追従 CTA まで 5 種。主役にはならないが、ページの手触りと導線を整える動き
コピーして貼るか、ファイルに保存して渡す。ネットにつながらない環境には「全部入り」を 1 つ渡せば足りる。
このパネルの中身を、URL の先にあるファイルの中身まで含めて 1 つにまとめた Markdown — 大きいのでファイルで渡す(コピーは 32KB まで)
# ベロシティスキュー ベロシティスキュー — Scroll-velocity skew 見出しの群を、スクロール速度に応じて斜めに傾ける。requestAnimationFrame で 1 フレームあたりのスクロール量を測り、速度 v に 0.02 を掛けて `skewY(clamp(-6deg, v * 0.02, 6deg))` の値にする。v は 60Hz の 1 フレームを基準に正規化し、画面の更新頻度が違っても傾きが変わらないようにする。 スクロールが止まると v を 1 フレームごとに 0.9 倍へ減衰させ、傾きは数分の 1 秒で 0 に戻る。傾けるのは見出しと本文を含む 5 つの区画で、各区画は 80vh の高さを持つ。 CSS 変数 `--skew` を経由して `transform` だけを書き換え、ライブラリは使わない。 外すと崩れる点: - 傾きは ±6 度で頭打ちにし、どれほど速くても崩れない - 止まったら傾きは減衰して 0 に戻り、静止時に傾いたまま残らない - 動きを減らす設定では傾けず、計測の処理も始めない 動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-velocity-skew/demo.html --- # スクロール回転 スクロール回転 — Rotate on scroll かみ合う歯車を 2 枚並べ、スクロールに合わせて回す。左の歯車は時計回りに 360 度、右の歯車は反時計回りに 360 度、スクロール量に対して同じ速さで回る。 歯車は歯数 14 の SVG の `path` で描き、2 枚の歯は半ピッチずらして描いて、回ったときも歯が噛み合ったまま見えるようにする。 区画は 300vh の高さにし、中に 100vh の sticky の箱を置く。回転は `transform: rotate(calc(var(--p) * ±360deg))` だけで行い、`--p` は区画のスクロール量から受け取る。 CSS と素の JavaScript だけで実装し、ライブラリは使わない。 外すと崩れる点: - 2 枚は逆向きに、同じ量(±360 度)回る - 歯数は 2 枚とも 14 で、噛み合う位置は半ピッチずらして描いておく(回転で位置を変えない) - 回転は rotate() だけで行い、歯車の位置や大きさは動かさない - 動きを減らす設定では歯車を噛み合った初期の位置で止める 動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-rotate-on-scroll/demo.html --- # 円形プログレス 円形プログレス — Circular scroll progress 画面の右下に、直径 72px の円形プログレスを固定して置く。外側の輪は SVG の circle で、stroke-dasharray を 1(pathLength="1")にし、stroke-dashoffset を 1 から 0 へ動かして、ページ全体のスクロール量 0〜100% に合わせて輪を時計回りに描く。 輪の中央には現在の百分率を「45%」のように表示し、等幅の数字を使う。 読了量が 90% を超えたら中央の表示を「↑」に切り替え、押すと先頭へ滑らかにスクロールして戻る(動きを減らす設定では即時)。 輪の線は --accent、地の輪は --rule で描き、色は :root の名前だけを使う。CSS と素の JavaScript だけで実装し、ライブラリは使わない。 外すと崩れる点: - 輪の進みはページ全体のスクロール量と一致し、最後の 1 段で 100% に届く - 動きを減らす設定を除き、「↑」は 90% を超えたときだけ出し、それ以外では押せない - 動きを減らす設定では、輪は完成した状態で止めて見せる 動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-circular-progress/demo.html --- # ブレンド反転 ブレンド反転 — mix-blend-mode invert 直径 50vmin の固定した円を画面の中央に置き、色を `var(--ground)` にして `mix-blend-mode: difference` で重ねる。 その下に地色の異なる区画を 3 つ縦に並べる(白、濃紺、青)。区画は 300vh の sticky 区画の中で、--p に合わせて 200vh ぶん上へ送る。 境目が円の中央を通るたびに、円と重なった部分だけが反転して見える。円は --p に合わせて 0.8 倍から 1.2 倍へ広がり、文字は円の外に置く。 CSS と素の JavaScript だけで実装し、ライブラリは使わない。 外すと崩れる点: - 反転は円と重なった部分だけに起き、円の外の地色は変えない - 円の色は var(--ground) の 1 色だけで、区画ごとに変えない - 文字は円の外に置き、ブレンドが効かなくても読める - 動きを減らす設定では、区画の境目が円の中央に来た状態で止める 動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-blend-invert/demo.html --- # 追従 CTA 追従 CTA — Sticky CTA bar 画面下端に固定したボタン帯(高さ約 60px、ボタン 1 つと一行の案内文)を置く。 名札(高さ 72vh)を抜けて、さらに画面 1 つぶん進んだところで、帯を translateY(100%) の位置から translateY(0) へ 0.7 秒の ease で持ち上げる。 終わりの帯(`.dv-end`)の上端が画面の下端に入ったら、帯を下へ戻して隠す。 実装は CSS の transition とスクロールの位置を読む 1 本の passive リスナーだけで行い、ライブラリは使わない。 外すと崩れる点: - 名札の中では出さない。名札を抜けて一画面ぶん進んでから初めて現れる - 終わりの帯に着いたら隠す。帯がボタンと重なって最後の見出しを隠さないようにする - ボタンは一行に収める。375px でも折り返さず、案内文は省略して収める - 動きを減らす設定では JS で隠さず、帯は完成した位置に止めて常に見せる 動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-sticky-cta/demo.html
# ベロシティスキュー https://design-vault.pages.dev/ai/assets/motion-velocity-skew.json --- # スクロール回転 https://design-vault.pages.dev/ai/assets/motion-rotate-on-scroll.json --- # 円形プログレス https://design-vault.pages.dev/ai/assets/motion-circular-progress.json --- # ブレンド反転 https://design-vault.pages.dev/ai/assets/motion-blend-invert.json --- # 追従 CTA https://design-vault.pages.dev/ai/assets/motion-sticky-cta.json
/designs/motion-velocity-skew/demo.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>40 ベロシティスキュー — Scroll-velocity skew</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(--s7); }
.vs-block { min-height: 80vh; display: grid; align-content: center; }
.vs-group { transform: skewY(var(--skew, 0deg)); transform-origin: 0 50%; will-change: transform; }
.vs-tag { font-family: var(--mono); font-size: var(--t-label); color: var(--accent); letter-spacing: .12em; }
@media (prefers-reduced-motion: reduce) {
.vs-group { transform: none; }
}
</style>
</head>
<body data-motion-number="40">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">40</p>
<h1 class="dv-name">ベロシティスキュー</h1>
<p class="dv-ai">Scroll-velocity skew</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="vs-block">
<div class="vs-group">
<p class="vs-tag">Section 1</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。速くスクロールすると、見出しの群が傾きます。</p>
</div>
</section>
<section class="vs-block">
<div class="vs-group">
<p class="vs-tag">Section 2</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。止めると、傾きは減衰して 0 に戻ります。</p>
</div>
</section>
<section class="vs-block">
<div class="vs-group">
<p class="vs-tag">Section 3</p>
<h2 class="dv-h2">見出しが入ります</h2>
<div class="dv-ph"></div>
</div>
</section>
<section class="vs-block">
<div class="vs-group">
<p class="vs-tag">Section 4</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。動きを減らす設定では、傾かずに静止したままです。</p>
</div>
</section>
<section class="vs-block">
<div class="vs-group">
<p class="vs-tag">Section 5</p>
<h2 class="dv-h2">見出しが入ります</h2>
<div class="dv-ph dv-ph--alt"></div>
</div>
</section>
</div>
</main>
<footer class="dv-end">END — 40 ベロシティスキュー</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 ---- */
// スクロール速度(60Hz の 1 フレームあたりの px)を測り、減衰させながら
// 見出し群の --skew に写す。止まれば 0 に戻る。動きを減らす設定では動かさない。
if (!dvReduce) {
const groups = [...document.querySelectorAll('.vs-group')];
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
const decay = 0.9;
let lastY = scrollY;
let lastT = 0;
let v = 0;
let running = false;
const frame = (now) => {
const dt = lastT ? Math.min(now - lastT, 100) : 16.67;
lastT = now;
const dy = scrollY - lastY;
lastY = scrollY;
v = dy !== 0 ? (dy / dt) * 16.67 : v * Math.pow(decay, dt / 16.67);
const skew = clamp(v * 0.02, -6, 6);
for (const el of groups) el.style.setProperty('--skew', skew.toFixed(3) + 'deg');
if (Math.abs(v) > 0.01) {
requestAnimationFrame(frame);
} else {
v = 0;
running = false;
lastT = 0;
for (const el of groups) el.style.setProperty('--skew', '0deg');
}
};
// lastY はここでは更新しない。scroll イベントは同じフレームの rAF の直前に来るので、
// ここで scrollY を写すと frame の差分が 0 になり、速さが一度も測れない。
const start = () => {
if (running) return;
running = true;
requestAnimationFrame(frame);
};
addEventListener('scroll', start, { passive: true });
}
</script>
</body>
</html>
/designs/motion-rotate-on-scroll/demo.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>41 スクロール回転 — Rotate on scroll</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); }
/* 歯車は 2 枚とも歯数 14。歯の半ピッチずれは path の座標に焼いてあり、回転は rotate() だけ */
.gears { height: 300vh; }
.gears-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; overflow: hidden; }
.gears svg { width: min(100% - 2 * var(--s4), 560px); height: auto; display: block; }
.gear { fill: var(--accent); }
.hub, .mark { fill: var(--ground); }
.spin-a { transform-origin: 140px 120px; transform: rotate(calc(var(--p) * 360deg)); }
.spin-b { transform-origin: 306px 120px; transform: rotate(calc(var(--p) * -360deg)); }
@media (prefers-reduced-motion: reduce) {
.spin-a, .spin-b { transform: none; }
}
</style>
</head>
<body data-motion-number="41">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">41</p>
<h1 class="dv-name">スクロール回転</h1>
<p class="dv-ai">Rotate on scroll</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">
<p class="dv-eyebrow">Section 1</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。このあとの区画で、歯車がかみ合ったまま回ります。</p>
</section>
</div>
<section class="gears" data-progress="pin">
<div class="gears-stick">
<svg viewBox="40 20 366 200" role="img" aria-label="かみ合う 2 枚の歯車">
<g class="spin-a">
<path class="gear" d="M215.44 110.81L229.71 112.74L229.71 127.26L215.44 129.19L211.96 144.46L223.97 152.38L217.67 165.47L203.99 161.01L194.22 173.26L201.61 185.61L190.25 194.66L179.86 184.71L165.74 191.51L167.04 205.84L152.88 209.07L147.83 195.60L132.17 195.60L127.12 209.07L112.96 205.84L114.26 191.51L100.14 184.71L89.75 194.66L78.39 185.61L85.78 173.26L76.01 161.01L62.33 165.47L56.03 152.38L68.04 144.46L64.56 129.19L50.29 127.26L50.29 112.74L64.56 110.81L68.04 95.54L56.03 87.62L62.33 74.53L76.01 78.99L85.78 66.74L78.39 54.39L89.75 45.34L100.14 55.29L114.26 48.49L112.96 34.16L127.12 30.93L132.17 44.40L147.83 44.40L152.88 30.93L167.04 34.16L165.74 48.49L179.86 55.29L190.25 45.34L201.61 54.39L194.22 66.74L203.99 78.99L217.67 74.53L223.97 87.62L211.96 95.54Z"/>
<circle class="hub" cx="140" cy="120" r="16"/>
<circle class="mark" cx="140" cy="70" r="7"/>
</g>
<g class="spin-b">
<path class="gear" d="M381.60 127.83L395.07 132.88L391.84 147.04L377.51 145.74L370.71 159.86L380.66 170.25L371.61 181.61L359.26 174.22L347.01 183.99L351.47 197.67L338.38 203.97L330.46 191.96L315.19 195.44L313.26 209.71L298.74 209.71L296.81 195.44L281.54 191.96L273.62 203.97L260.53 197.67L264.99 183.99L252.74 174.22L240.39 181.61L231.34 170.25L241.29 159.86L234.49 145.74L220.16 147.04L216.93 132.88L230.40 127.83L230.40 112.17L216.93 107.12L220.16 92.96L234.49 94.26L241.29 80.14L231.34 69.75L240.39 58.39L252.74 65.78L264.99 56.01L260.53 42.33L273.62 36.03L281.54 48.04L296.81 44.56L298.74 30.29L313.26 30.29L315.19 44.56L330.46 48.04L338.38 36.03L351.47 42.33L347.01 56.01L359.26 65.78L371.61 58.39L380.66 69.75L370.71 80.14L377.51 94.26L391.84 92.96L395.07 107.12L381.60 112.17Z"/>
<circle class="hub" cx="306" cy="120" r="16"/>
<circle class="mark" cx="306" cy="70" r="7"/>
</g>
</svg>
</div>
</section>
<div class="dv-wrap stack">
<section class="dv-card">
<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 — 41 スクロール回転</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 ---- */
dvProgress();
</script>
</body>
</html>
/designs/motion-circular-progress/demo.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>42 円形プログレス — Circular scroll progress</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); }
.ring {
position: fixed;
right: var(--s3);
bottom: var(--s3);
z-index: 10;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--ground);
box-shadow: 0 2px 12px var(--shade);
display: grid;
place-items: center;
}
.ring svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.ring-track { fill: none; stroke: var(--rule); stroke-width: 4; }
.ring-fg {
fill: none;
stroke: var(--accent);
stroke-width: 4;
stroke-linecap: round;
stroke-dasharray: 1;
stroke-dashoffset: calc(1 - var(--p, 0));
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
.ring-num {
font-family: var(--mono);
font-size: var(--t-label);
font-weight: 700;
color: var(--ink);
font-variant-numeric: tabular-nums;
transition: opacity var(--dur) var(--ease);
}
.ring-btn {
position: absolute;
inset: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--accent);
font: inherit;
font-size: var(--t-lead);
font-weight: 700;
cursor: pointer;
opacity: 0;
pointer-events: none;
}
.ring.is-top .ring-num { opacity: 0; }
.ring.is-top .ring-btn { opacity: 1; pointer-events: auto; }
.ring-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.ring-fg { stroke-dashoffset: 0; }
}
</style>
</head>
<body data-motion-number="42">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">42</p>
<h1 class="dv-name">円形プログレス</h1>
<p class="dv-ai">Circular scroll progress</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">
<p class="dv-eyebrow">Section 1</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。右下の輪が、どこまで読んだかを示します。</p>
</section>
<div class="dv-ph"></div>
<section class="dv-card">
<p class="dv-eyebrow">Section 2</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。読み進めるほど、輪の線が伸びていきます。</p>
</section>
<div class="dv-ph dv-ph--alt"></div>
<section class="dv-card">
<p class="dv-eyebrow">Section 3</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。ここまでの量が、輪の中の百分率で読めます。</p>
</section>
<div class="dv-ph dv-ph--ink"></div>
<section class="dv-card">
<p class="dv-eyebrow">Section 4</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。終わり近くで、輪の中の印が矢印に変わります。</p>
</section>
<div class="dv-ph"></div>
<section class="dv-card">
<p class="dv-eyebrow">Section 5</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。矢印を押すと、先頭まで戻ります。</p>
</section>
<div class="dv-ph dv-ph--alt"></div>
</div>
</main>
<div class="ring" data-progress="page">
<svg viewBox="0 0 72 72" aria-hidden="true">
<circle class="ring-track" cx="36" cy="36" r="30"></circle>
<circle class="ring-fg" cx="36" cy="36" r="30" pathLength="1"></circle>
</svg>
<span class="ring-num">0%</span>
<button class="ring-btn" type="button" aria-label="先頭へ戻る" disabled>↑</button>
</div>
<footer class="dv-end">END — 42 円形プログレス</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();
const ring = document.querySelector('.ring');
const ringNum = ring.querySelector('.ring-num');
const ringBtn = ring.querySelector('.ring-btn');
dvProgress((el, p) => {
// 動きを減らす設定では、輪は完成した状態で止めて見せる
const shown = dvReduce ? 1 : p;
const top = shown > 0.9;
ringNum.textContent = Math.round(shown * 100) + '%';
el.classList.toggle('is-top', top);
ringBtn.disabled = !top;
});
ringBtn.addEventListener('click', () => {
scrollTo({ top: 0, behavior: dvReduce ? 'auto' : 'smooth' });
});
</script>
</body>
</html>
/designs/motion-blend-invert/demo.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>43 ブレンド反転 — mix-blend-mode invert</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() のみ ---- */
.bi-pin { position: relative; height: 300vh; }
.bi-stick { position: sticky; top: 0; height: 100vh; overflow: hidden; isolation: isolate; }
.bi-track {
position: absolute; top: 0; left: 0; right: 0;
transform: translate3d(0, calc(var(--p) * -200vh), 0);
will-change: transform;
}
.bi-zone { position: relative; height: 100vh; color: var(--ink); }
.bi-zone--light { background: var(--ground); }
.bi-zone--dark { background: var(--ink); color: var(--ground); }
.bi-zone--accent { background: var(--accent); color: var(--ground); }
.bi-zone .dv-eyebrow, .bi-zone .dv-lead { color: inherit; }
.bi-copy { position: absolute; left: var(--s4); right: var(--s4); top: 10vh; }
.bi-disc {
position: absolute; left: 50%; top: 50%;
width: 50vmin; aspect-ratio: 1;
border-radius: 50%;
background: var(--ground);
mix-blend-mode: difference;
transform: translate(-50%, -50%) scale(calc(0.8 + var(--p) * 0.4));
pointer-events: none;
}
@media (min-width: 768px) {
.bi-copy { right: auto; width: 34%; }
}
@media (prefers-reduced-motion: reduce) {
.bi-track { transform: translate3d(0, -50vh, 0); }
.bi-disc { transform: translate(-50%, -50%); }
}
</style>
</head>
<body data-motion-number="43">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">43</p>
<h1 class="dv-name">ブレンド反転</h1>
<p class="dv-ai">mix-blend-mode invert</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">
<p class="dv-eyebrow">Section 1</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。このあとの区画を、円がまたいで反転させます。</p>
</section>
</div>
<section class="bi-pin" data-progress="pin">
<div class="bi-stick">
<div class="bi-track">
<div class="bi-zone bi-zone--light">
<div class="bi-copy">
<p class="dv-eyebrow">Zone 1</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。</p>
</div>
</div>
<div class="bi-zone bi-zone--dark">
<div class="bi-copy">
<p class="dv-eyebrow">Zone 2</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。</p>
</div>
</div>
<div class="bi-zone bi-zone--accent">
<div class="bi-copy">
<p class="dv-eyebrow">Zone 3</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。</p>
</div>
</div>
</div>
<span class="bi-disc" aria-hidden="true"></span>
</div>
</section>
<div class="dv-wrap stack">
<section class="dv-card">
<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 — 43 ブレンド反転</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 ---- */
dvProgress();
</script>
</body>
</html>
/designs/motion-sticky-cta/demo.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>44 追従 CTA — Sticky CTA bar</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() のみ ---- */
.hd-section { min-height: 80vh; display: grid; align-content: center; }
.cta {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
padding: var(--s2) var(--s4);
background: color-mix(in srgb, var(--ground) 94%, transparent);
border-top: 1px solid var(--rule);
transition: transform var(--dur) var(--ease), visibility var(--dur);
}
.cta-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--t-label); color: var(--muted); }
.cta-btn {
flex-shrink: 0;
white-space: nowrap;
padding: var(--s2) var(--s4);
border-radius: var(--radius);
background: var(--accent);
color: var(--ground);
font-weight: 700;
text-decoration: none;
}
.js .cta { transform: translateY(100%); visibility: hidden; }
.js .cta.is-shown { transform: none; visibility: visible; }
@media (prefers-reduced-motion: reduce) {
.cta { transition: none; }
.js .cta { transform: none; visibility: visible; }
}
</style>
</head>
<body data-motion-number="44">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">44</p>
<h1 class="dv-name">追従 CTA</h1>
<p class="dv-ai">Sticky CTA bar</p>
<p class="dv-desc">途中から下端にボタン帯が現れる</p>
<p class="dv-hint">SCROLL ↓</p>
</div>
</header>
<main class="dv-stage">
<aside class="cta" aria-label="案内">
<p class="cta-text">資料のご請求はこちらから</p>
<a class="cta-btn" href="#">無料で始める</a>
</aside>
<section class="hd-section">
<div class="dv-wrap">
<p class="dv-eyebrow">Section 1</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。名札を抜けて一画面ぶん進むと、下端に帯が現れます。</p>
</div>
</section>
<section class="hd-section">
<div class="dv-wrap">
<p class="dv-eyebrow">Section 2</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。帯は読み進めているあいだ、下端に残ります。</p>
</div>
</section>
<section class="hd-section">
<div class="dv-wrap">
<p class="dv-eyebrow">Section 3</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。終わりの帯が画面に入ると、帯は隠れます。</p>
</div>
</section>
<section class="hd-section">
<div class="dv-wrap">
<p class="dv-eyebrow">Section 4</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。ここまでが演出の舞台です。</p>
</div>
</section>
</main>
<footer class="dv-end">END — 44 追従 CTA</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 ---- */
const dvCta = document.querySelector('.cta');
const dvLabel = document.querySelector('.dv-label');
const dvEnd = document.querySelector('.dv-end');
let dvQueued = false;
/** 名札を抜けて一画面ぶん進んだら帯を出し、終わりの帯が入ったら隠す。 */
function dvCtaMeasure() {
dvQueued = false;
const vh = innerHeight;
const past = dvLabel.getBoundingClientRect().bottom <= -vh;
const atEnd = dvEnd.getBoundingClientRect().top < vh;
dvCta.classList.toggle('is-shown', past && !atEnd);
}
if (!dvReduce) {
addEventListener('scroll', () => {
if (!dvQueued) { dvQueued = true; requestAnimationFrame(dvCtaMeasure); }
}, { passive: true });
addEventListener('resize', dvCtaMeasure);
dvCtaMeasure();
}
</script>
</body>
</html>