Design Vault 用途・構成・配色・本文で検索 /
索引 カテゴリ タグ English

一式

空気感と補助 40–44 — 読み心地を整える演出

ベロシティスキューから追従 CTA まで 5 種。主役にはならないが、ページの手触りと導線を整える動き

5 枚

持ち帰る JSON 何枚あっても 1 回で。順に束ねて写すので、貼った先でも並びが残る。 仕様の無い枠は、題名と JSON の場所だけを書く

読む順

  1. 01 動き ベロシティスキュー ベロシティスキュー
  2. 02 動き スクロール回転 スクロール回転
  3. 03 動き 円形プログレス 円形プログレス
  4. 04 動き ブレンド反転 ブレンド反転
  5. 05 動き 追従 CTA 追従 CTA

この一式について

持ち帰る

コピーして貼るか、ファイルに保存して渡す。ネットにつながらない環境には「全部入り」を 1 つ渡せば足りる。

全部入り

scroll-motion-ambient-everything.md

このパネルの中身を、URL の先にあるファイルの中身まで含めて 1 つにまとめた Markdown

依頼文

5.9 KB
# ベロシティスキュー
ベロシティスキュー — 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

仕様

473 B
# ベロシティスキュー
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

ファイル

01 motion-velocity-skew/demo.html

9.4 KB

/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>

02 motion-rotate-on-scroll/demo.html

9.6 KB

/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>

03 motion-circular-progress/demo.html

10.0 KB

/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>

04 motion-blend-invert/demo.html

8.9 KB

/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>

05 motion-sticky-cta/demo.html

9.3 KB

/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>