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

一式

スクロールに連動する動き 09–16 — 進めた分だけ進む演出

読了バーからマーキーまで 8 種。時間ではなくスクロール量で進むので、止めれば止まり、戻せば戻る

8 枚

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

読む順

  1. 01 動き スクロール連動 スクロール連動
  2. 02 動き パララックス パララックス
  3. 03 動き スクロールズーム スクロールズーム
  4. 04 動き 画像シーケンス 画像シーケンス
  5. 05 動き SVGラインドロー SVGラインドロー
  6. 06 動き テキストフィル テキストフィル
  7. 07 動き 背景色トランジション 背景色トランジション
  8. 08 動き マーキー マーキー

この一式について

持ち帰る

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

全部入り

scroll-motion-linked-everything.md

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

依頼文

8.5 KB
# スクロール連動
スクロール連動 — Scroll-linked animation

長い本文の上端に、高さ 4px の読了バーを固定する。バーは画面の左端から伸び、ページ末尾で幅がちょうど 100% になる。
伸びる部分は `scaleX(var(--p))` で幅を決め、transform-origin は左端に置く。--p はページ全体のスクロール量 `scrollY / (scrollHeight - innerHeight)` を 0〜1 に収めた値とし、scroll イベントを requestAnimationFrame で一度にまとめて読む。
バーの下地は薄いグレーの 1 本、伸びる部分はアクセント色の 1 本だけにする。本文は幅 1080px 以下、行間 1.7 で 8 節に分ける。
CSS と素の JavaScript だけで実装し、ライブラリは使わない。

外すと崩れる点:
- バーの幅はスクロール量だけで決まり、時間では進まない
- ページ末尾で幅がちょうど 100% になる
- 動きを減らす設定では transition を外し、即座に追従させる

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-scroll-linked/demo.html

---

# パララックス
パララックス — Parallax scrolling

3 層のパララックスを作る。奥の層(係数 0.3)、中の層(0.7)、手前の層(1.3)が、同じスクロール量に対して違う速さで上へ動く。
各層の `transform` は `translate3d(0, calc(var(--p) * var(--k) * -300px), 0)` とし、--p は 0〜1 の値を区画のスクロール量から受け取る。区画の高さは 240vh、層を収める固定の箱は 100vh の sticky にする。
手前の層ほど大きく、奥の層ほど薄くし、ぼかさずに色のグラデーションだけで奥行きを出す。
CSS と素の JavaScript だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 層の係数は奥ほど小さく、手前ほど大きい(0.3 / 0.7 / 1.3)
- すべての層は 1 つの sticky の箱に収まり、画面の外へ漏れない
- 動きを減らす設定では層を動かさず、重ねた状態で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-parallax/demo.html

---

# スクロールズーム
スクロールズーム — Scale on scroll

画像が画面の半分の大きさで現れ、スクロールに合わせて全画面まで広がる。区画の高さは 300vh、画像を収める箱は 100vh の position: sticky にする。
拡大率は `scale(calc(0.5 + var(--p) * 0.5))` とし、角丸は `calc((1 - var(--p)) * 32px)` で、--p が 1 になったところで角丸を 0 にする。
画像の前後には本文を 1 段ずつ置き、拡大が終わってから次の本文が見えるようにする。
CSS と素の JavaScript だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 拡大は区画の中で完結し、箱は sticky で画面に留まる
- 拡大率は 0.5 から 1 の範囲に収める
- 動きを減らす設定では拡大せず、全画面の状態で表示する

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-scroll-zoom/demo.html

---

# 画像シーケンス
画像シーケンス — Scroll-scrubbed image sequence

canvas に 48 コマの連番画像を描き、スクロール量に応じてコマを切り替える。区画の高さは 300vh、canvas を収める箱は 100vh の sticky にする。
--p に 47 を掛けて四捨五入し、0〜47 のコマ番号を求める。コマ n の図形は、中心の輪の上を 3 つの点が回る形とし、角度は `2π・n/48 + k・2π/3`(k は点の番号 0〜2)で決める。
色は `getComputedStyle(document.documentElement).getPropertyValue('--accent')` などで :root の役割名から読み、描画のたびに使う。
CSS と素の JavaScript だけで実装し、ライブラリは使わない。

外すと崩れる点:
- コマは 48 枚固定で、スクロール位置に対応する 1 コマだけを描く
- 色は :root の役割名だけから読み、JS に色の値を直接書かない
- 動きを減らす設定では最後のコマを 1 枚だけ描いて止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-image-sequence/demo.html

---

# SVGラインドロー
SVGラインドロー — SVG line drawing

見出しの下に、3 本の SVG の曲線を描く。3 本は p の 0〜0.36、0.32〜0.68、0.64〜1.00 の区間で順に描かれ、隣とわずかに重なる。
各線は 0% から 100% まで、スクロール量に合わせて左から右へ描かれる。
線は accent の色、太さ 4px、端は丸くし、背景には rule の色で水平のグリッド線を 3 本引く。
見出しと線は画面上端に固定し、300vh の区画を下へスクロールする間だけ描画が進む。上へ戻すと線も戻る。
CSS の stroke-dasharray と stroke-dashoffset だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 線は pathLength=1 で描き、描画の進みは --p だけで決める
- スクロールを戻すと線も戻る
- 動きを減らす設定では、描き終わった形を表示する

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-svg-line-draw/demo.html

---

# テキストフィル
テキストフィル — Text fill on scroll

1 段落(約 40 語)を語ごとに <span> に割り、最初は全語を薄い色(ink を 16% 混ぜた ground)で表示する。
区画のスクロール量 p(0〜1)に対して、先頭から p × 語数 番目までの語を ink に塗る。
色の変化は 0.7 秒の transition でなめらかにし、塗りは読み進める向きにだけ進む。
区画は 300vh、本文は 100vh の sticky 箱に固定し、全語が塗られるのは区画の最後である。
色は ink と color-mix() だけで指定し、ライブラリは使わない。

外すと崩れる点:
- 塗りは進捗と同じ向きにだけ進み、スクロールを戻すと色も戻る
- 句読点や空白は割らず、語の単位は Intl.Segmenter の語に従う
- 動きを減らす設定では、全文を ink で表示する

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-text-fill/demo.html

---

# 背景色トランジション
背景色トランジション — Background color transition

画面を 4 つの区画(各 100vh)に分け、それぞれに tone の名前(ground、surface、accent-soft、ink)を持たせる。
画面の中央を横切っている区画の tone を body の data-tone に書き、背景色を 0.6 秒の transition で切り替える。
ink の区画だけは背景を ink、文字色を ground に切り替え、補助の文字は surface で読めるようにする。
色は :root の 4 つの名前(--ground、--surface、--accent-soft、--ink)と var() だけで指定し、16 進数は書かない。
中央を横切る区画の判定は getBoundingClientRect だけで行い、ライブラリは使わない。

外すと崩れる点:
- 背景色は :root の名前だけで指定する
- 背景を切り替えるのは、画面中央を横切る区画の tone 1 つだけ
- 動きを減らす設定では、切り替えを即時にする

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-bg-color-transition/demo.html

---

# マーキー
マーキー — Marquee (scroll velocity)

上下 2 本の帯(文字は見出し大、40〜72px)を 100vh の sticky 箱に置き、帯の外へ出る文字は overflow: hidden で隠す。
上の帯は左へ、下の帯は右へ、基本速度 60px/s で止まらずに流れ続ける。
1 フレームごとのスクロール量の 3 倍を速度に加算し、毎秒 2% まで減衰させる(スクロールを止めると 1 秒ほどで基本速度に近づく)。
同じ文字の組を 2 つ並べ、組の幅ぶん流れたら位置を戻して、継ぎ目を見せない。
requestAnimationFrame と transform だけで動かし、ライブラリは使わない。

外すと崩れる点:
- 帯は止めない。スクロールは速さを足すだけ
- 継ぎ目が見えないよう、同じ内容を 2 組並べる
- 動きを減らす設定では、流さずに先頭の位置で止める

動く実物(1 ファイルで完結する HTML): https://design-vault.pages.dev/designs/motion-marquee/demo.html

仕様

759 B
# スクロール連動
https://design-vault.pages.dev/ai/assets/motion-scroll-linked.json

---

# パララックス
https://design-vault.pages.dev/ai/assets/motion-parallax.json

---

# スクロールズーム
https://design-vault.pages.dev/ai/assets/motion-scroll-zoom.json

---

# 画像シーケンス
https://design-vault.pages.dev/ai/assets/motion-image-sequence.json

---

# SVGラインドロー
https://design-vault.pages.dev/ai/assets/motion-svg-line-draw.json

---

# テキストフィル
https://design-vault.pages.dev/ai/assets/motion-text-fill.json

---

# 背景色トランジション
https://design-vault.pages.dev/ai/assets/motion-bg-color-transition.json

---

# マーキー
https://design-vault.pages.dev/ai/assets/motion-marquee.json

ファイル

01 motion-scroll-linked/demo.html

10.3 KB

/designs/motion-scroll-linked/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>09 スクロール連動 — Scroll-linked animation</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); }
.readbar { position: fixed; top: 0; left: 0; right: 0; height: 4px; background: var(--rule); z-index: 10; }
.readbar-fill {
  position: absolute; inset: 0;
  background: var(--accent);
  transform-origin: 0 50%;
  transform: scaleX(var(--p, 0));
  transition: transform .12s linear;
}
.article { display: grid; gap: var(--s6); }
.article-body { display: grid; gap: var(--s3); margin-top: var(--s3); }

@media (prefers-reduced-motion: reduce) {
  .readbar-fill { transition: none; }
}
</style>
</head>
<body data-motion-number="09">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">09</p>
    <h1 class="dv-name">スクロール連動</h1>
    <p class="dv-ai">Scroll-linked animation</p>
    <p class="dv-desc">スクロール量に合わせて進む(読了バー)</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="readbar" data-progress="page" aria-hidden="true"><div class="readbar-fill"></div></div>
  <div class="dv-wrap article">
    <section>
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <div class="article-body">
        <p class="dv-lead">本文のダミーです。読み始めると画面の上端に細い帯が現れ、スクロールした量に合わせて右へ伸びていきます。</p>
        <p>段落のダミーです。ここでは文章の長さを本物の記事に近づけ、帯がどの位置で何割まで進んでいるかを確かめられるようにしています。</p>
      </div>
    </section>
    <section>
      <p class="dv-eyebrow">Section 2</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <div class="article-body">
        <p>本文のダミーです。見出しや段落の高さは、実際の読み物を想定して少しずつ変えてあります。</p>
        <p>段落のダミーです。スクロールを止めれば帯も止まり、戻せば帯も戻ります。時間ではなく、位置だけで決まる動きです。</p>
      </div>
    </section>
    <section>
      <p class="dv-eyebrow">Section 3</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <div class="article-body">
        <p>本文のダミーです。文章の途中で読むのをやめた人にも、どこまで読んだかが一目で伝わります。</p>
        <p>段落のダミーです。ここでは中立な文だけを置いています。</p>
      </div>
    </section>
    <section>
      <p class="dv-eyebrow">Section 4</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <div class="article-body">
        <p>本文のダミーです。読了の目安として、帯の長さが全体の進み具合と揃っていることを確かめてください。</p>
        <p>段落のダミーです。本文の行間は 1.7 で、幅は 1080px を超えません。</p>
      </div>
    </section>
    <section>
      <p class="dv-eyebrow">Section 5</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <div class="article-body">
        <p>本文のダミーです。段落が続くと、帯は少しずつ、着実に伸びていきます。</p>
        <p>段落のダミーです。ひとつの節が長くても、帯の進みは一定です。</p>
      </div>
    </section>
    <section>
      <p class="dv-eyebrow">Section 6</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <div class="article-body">
        <p>本文のダミーです。中盤に入り、残りの長さを感じ取れるのがこの帯の役目です。</p>
        <p>段落のダミーです。ここでも説明は必要最小限にしています。</p>
      </div>
    </section>
    <section>
      <p class="dv-eyebrow">Section 7</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <div class="article-body">
        <p>本文のダミーです。終わりが近づくと、帯はほとんど右端に届いています。</p>
        <p>段落のダミーです。残りの段落は、あと一節だけです。</p>
      </div>
    </section>
    <section>
      <p class="dv-eyebrow">Section 8</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <div class="article-body">
        <p>本文のダミーです。最後の段落まで読み終えると、帯は右端いっぱいに伸びきります。</p>
        <p>段落のダミーです。これで本文は終わりです。</p>
      </div>
    </section>
  </div>
</main>

<footer class="dv-end">END — 09 スクロール連動</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>

02 motion-parallax/demo.html

8.8 KB

/designs/motion-parallax/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>10 パララックス — Parallax scrolling</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); }
.plx { position: relative; height: 240vh; }
.plx-stick { position: sticky; top: 0; height: 100vh; overflow: hidden; }
.plx-layer {
  position: absolute; inset: 0;
  transform: translate3d(0, calc(var(--p) * var(--k) * -300px), 0);
  will-change: transform;
}
.plx-mid { display: grid; place-items: center; }
.plx-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--s3); }
.plx-blob {
  position: absolute;
  left: -12%; top: 8%;
  width: 70vmin; aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--accent-soft), transparent);
}
.plx-blob--2 { left: auto; right: -8%; top: 52%; width: 50vmin; background: radial-gradient(closest-side, var(--accent-mid), transparent); }
.plx-dot {
  position: absolute;
  width: 14px; aspect-ratio: 1;
  border-radius: 50%;
  background: var(--accent-mid);
}
.plx-dot:nth-child(1) { left: 10%; top: 14%; }
.plx-dot:nth-child(2) { left: 82%; top: 30%; width: 22px; }
.plx-dot:nth-child(3) { left: 22%; top: 70%; width: 28px; }
.plx-dot:nth-child(4) { left: 74%; top: 82%; }

@media (prefers-reduced-motion: reduce) {
  .plx-layer { transform: none; }
}
</style>
</head>
<body data-motion-number="10">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">10</p>
    <h1 class="dv-name">パララックス</h1>
    <p class="dv-ai">Parallax scrolling</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="plx" data-progress="through">
    <div class="plx-stick">
      <div class="plx-layer plx-back" style="--k: 0.3">
        <span class="plx-blob"></span>
        <span class="plx-blob plx-blob--2"></span>
      </div>
      <div class="plx-layer plx-mid" style="--k: 0.7">
        <div class="dv-wrap plx-cards">
          <div class="dv-card"><div class="dv-ph"></div></div>
          <div class="dv-card"><div class="dv-ph dv-ph--alt"></div></div>
          <div class="dv-card"><div class="dv-ph dv-ph--ink"></div></div>
        </div>
      </div>
      <div class="plx-layer plx-front" style="--k: 1.3">
        <span class="plx-dot"></span>
        <span class="plx-dot"></span>
        <span class="plx-dot"></span>
        <span class="plx-dot"></span>
      </div>
    </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 — 10 パララックス</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-scroll-zoom/demo.html

7.6 KB

/designs/motion-scroll-zoom/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>11 スクロールズーム — Scale 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); }
.zoom { height: 300vh; }
.zoom-stick { position: sticky; top: 0; height: 100vh; overflow: hidden; }
.zoom-img {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  background: linear-gradient(135deg, var(--accent-soft), var(--accent-mid) 60%, var(--accent));
  transform: scale(calc(0.5 + var(--p) * 0.5));
  border-radius: calc((1 - var(--p)) * 32px);
}
.zoom-cap { font-size: var(--t-h2); font-weight: 700; color: var(--ground); }

@media (prefers-reduced-motion: reduce) {
  .zoom-img { transform: none; border-radius: 0; }
}
</style>
</head>
<body data-motion-number="11">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">11</p>
    <h1 class="dv-name">スクロールズーム</h1>
    <p class="dv-ai">Scale 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="zoom" data-progress="pin">
    <div class="zoom-stick">
      <div class="zoom-img"><p class="zoom-cap">見出しが入ります</p></div>
    </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 — 11 スクロールズーム</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>

04 motion-image-sequence/demo.html

9.2 KB

/designs/motion-image-sequence/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>12 画像シーケンス — Scroll-scrubbed image sequence</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); }
.seq { height: 300vh; }
.seq-stick {
  position: sticky; top: 0; height: 100vh;
  display: grid; place-content: center; justify-items: center; gap: var(--s3);
}
/* 幅は画面から決める。中央寄せの grid の中で % を使うと、基準の列が中身の幅に
   なって、キャンバスが点まで縮む。 */
.seq-canvas { display: block; width: min(100vw - 2 * var(--s4), 60vh, 560px); aspect-ratio: 1; }
.seq-count {
  font-family: var(--mono);
  font-size: var(--t-label);
  color: var(--muted);
  letter-spacing: .12em;
}

@media (prefers-reduced-motion: reduce) {
  .seq { height: auto; }
  .seq-stick { position: static; height: auto; padding-block: var(--s6); }
}
</style>
</head>
<body data-motion-number="12">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">12</p>
    <h1 class="dv-name">画像シーケンス</h1>
    <p class="dv-ai">Scroll-scrubbed image sequence</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="seq" data-progress="pin">
    <div class="seq-stick">
      <canvas class="seq-canvas" aria-hidden="true"></canvas>
      <p class="seq-count">01 / 48</p>
    </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 — 12 画像シーケンス</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 FRAMES = 48;
const seqCanvas = document.querySelector('.seq-canvas');
const seqCount = document.querySelector('.seq-count');

/** :root の役割名から色を読む。描画のたびに読むので、配色の変更にも追従する。 */
const role = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();

/** コマ n(0〜47)を描く。中心の輪の上を、3 つの点が一周ぶん回る。 */
function drawFrame(n) {
  const dpr = window.devicePixelRatio || 1;
  const size = Math.round(seqCanvas.clientWidth * dpr);
  if (seqCanvas.width !== size) { seqCanvas.width = size; seqCanvas.height = size; }
  const ctx = seqCanvas.getContext('2d');
  const c = size / 2;
  const r = size * 0.32;

  ctx.fillStyle = role('--ground');
  ctx.fillRect(0, 0, size, size);

  ctx.strokeStyle = role('--rule');
  ctx.lineWidth = 2 * dpr;
  ctx.beginPath();
  ctx.arc(c, c, r, 0, Math.PI * 2);
  ctx.stroke();

  for (let k = 0; k < 3; k++) {
    const a = (n / FRAMES) * Math.PI * 2 + (k * Math.PI * 2) / 3;
    ctx.fillStyle = k === 0 ? role('--accent') : role('--ink');
    ctx.beginPath();
    ctx.arc(c + Math.cos(a) * r, c + Math.sin(a) * r, size * (k === 0 ? 0.05 : 0.035), 0, Math.PI * 2);
    ctx.fill();
  }
  seqCount.textContent = `${String(n + 1).padStart(2, '0')} / ${FRAMES}`;
}

dvProgress((el, p) => {
  drawFrame(dvReduce ? FRAMES - 1 : Math.round(p * (FRAMES - 1)));
});
</script>
</body>
</html>

05 motion-svg-line-draw/demo.html

7.8 KB

/designs/motion-svg-line-draw/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>13 SVGラインドロー — SVG line drawing</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() のみ ---- */
.draw-zone { height: 300vh; }
.draw-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; }
.draw-stick .dv-wrap { display: grid; gap: var(--s4); }
.draw-svg { display: block; width: 100%; height: auto; overflow: visible; }
.draw-grid { fill: none; stroke: var(--rule); stroke-width: 2; }
.draw-line {
  fill: none;
  stroke: var(--accent);
  stroke-width: 4;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: calc(1 - clamp(0, (var(--p) - var(--from)) / var(--span), 1));
}
.draw-caption { font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; }

@media (prefers-reduced-motion: reduce) {
  .draw-line { stroke-dashoffset: 0; }
}
</style>
</head>
<body data-motion-number="13">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">13</p>
    <h1 class="dv-name">SVGラインドロー</h1>
    <p class="dv-ai">SVG line drawing</p>
    <p class="dv-desc">線が描かれていく</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="draw-zone" data-progress="pin">
    <div class="draw-stick">
      <div class="dv-wrap">
        <h2 class="dv-h2">見出しが入ります</h2>
        <svg class="draw-svg" viewBox="0 0 800 400" aria-hidden="true">
          <path class="draw-grid" d="M0 100H800M0 200H800M0 300H800"/>
          <path class="draw-line" pathLength="1" style="--from: 0; --span: .36" d="M20 320C120 300 160 120 260 180S380 330 460 210S640 60 780 90"/>
          <path class="draw-line" pathLength="1" style="--from: .32; --span: .36" d="M20 90C160 60 220 260 360 240S560 40 780 300"/>
          <path class="draw-line" pathLength="1" style="--from: .64; --span: .36" d="M20 200C140 140 260 340 400 200S640 120 780 200"/>
        </svg>
        <p class="draw-caption">Section 1 — 本文のダミーです</p>
      </div>
    </div>
  </section>
</main>

<footer class="dv-end">END — 13 SVGラインドロー</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>

06 motion-text-fill/demo.html

8.1 KB

/designs/motion-text-fill/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>14 テキストフィル — Text fill 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() のみ ---- */
.fill-zone { height: 300vh; }
.fill-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; }
.fill-stick .dv-wrap { display: grid; gap: var(--s4); }
.fill-text { font-size: var(--t-h2); font-weight: 700; line-height: 1.7; }
.fill-word { transition: color var(--dur) var(--ease); }
.js .fill-word { color: color-mix(in srgb, var(--ink) 16%, var(--ground)); }
.js .fill-word.is-on { color: var(--ink); }

@media (prefers-reduced-motion: reduce) {
  .js .fill-word { color: var(--ink); transition: none; }
}
</style>
</head>
<body data-motion-number="14">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">14</p>
    <h1 class="dv-name">テキストフィル</h1>
    <p class="dv-ai">Text fill on scroll</p>
    <p class="dv-desc">読み進めるほど文字色が塗られる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="fill-zone" data-progress="pin">
    <div class="fill-stick">
      <div class="dv-wrap">
        <p class="dv-eyebrow">Section 1</p>
        <p class="fill-text">ここには読み進める文章が入ります。読んだところから順に文字の色が塗られ、まだ読んでいない語は薄いままです。</p>
      </div>
    </div>
  </section>
</main>

<footer class="dv-end">END — 14 テキストフィル</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 ---- */
// 文を語ごとに <span> に割る。読点や空白は割らずにそのまま残す。
const fillText = document.querySelector('.fill-text');
const source = fillText.textContent;
const words = [];
// 語に割れない環境では文をそのまま残す。先に消すと、割る前に舞台が空になる。
const segments = 'Segmenter' in Intl ? [...new Intl.Segmenter('ja', { granularity: 'word' }).segment(source)] : [];
if (segments.length > 0) fillText.textContent = '';
for (const { segment, isWordLike } of segments) {
  if (!isWordLike) {
    fillText.append(segment);
    continue;
  }
  const span = document.createElement('span');
  span.className = 'fill-word';
  span.textContent = segment;
  fillText.append(span);
  words.push(span);
}

// --p を超えた語から順に --ink にする。
dvProgress((el, p) => {
  words.forEach((w, i) => w.classList.toggle('is-on', p * words.length >= i + 1));
});
</script>
</body>
</html>

07 motion-bg-color-transition/demo.html

8.5 KB

/designs/motion-bg-color-transition/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>15 背景色トランジション — Background color transition</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() のみ ---- */
body { transition: background-color .6s ease, color .6s ease; }
body[data-tone="ground"] { background-color: var(--ground); }
body[data-tone="surface"] { background-color: var(--surface); }
body[data-tone="accent-soft"] { background-color: var(--accent-soft); }
body[data-tone="ink"] { background-color: var(--ink); color: var(--ground); }
body[data-tone="ink"] .dv-eyebrow,
body[data-tone="ink"] .dv-lead,
body[data-tone="ink"] .dv-end { color: var(--surface); }

.tone-zone { min-height: 100vh; display: grid; place-items: center; }
.tone-zone .dv-wrap { display: grid; gap: var(--s3); }

@media (prefers-reduced-motion: reduce) {
  body { transition: none; }
}
</style>
</head>
<body data-motion-number="15">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">15</p>
    <h1 class="dv-name">背景色トランジション</h1>
    <p class="dv-ai">Background color transition</p>
    <p class="dv-desc">背景色がなめらかに変わる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="tone-zone" data-progress="through" data-tone="ground">
    <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="tone-zone" data-progress="through" data-tone="surface">
    <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="tone-zone" data-progress="through" data-tone="accent-soft">
    <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="tone-zone" data-progress="through" data-tone="ink">
    <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 — 15 背景色トランジション</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 ---- */
// 画面の中央を横切っている区画の tone を body に写す。
dvProgress((el) => {
  const r = el.getBoundingClientRect();
  if (r.top <= innerHeight / 2 && r.bottom > innerHeight / 2) document.body.dataset.tone = el.dataset.tone;
});
</script>
</body>
</html>

08 motion-marquee/demo.html

9.0 KB

/designs/motion-marquee/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>16 マーキー — Marquee (scroll velocity)</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() のみ ---- */
.marquee-zone { height: 300vh; }
.marquee-stick { position: sticky; top: 0; height: 100vh; display: grid; align-content: center; gap: var(--s5); }
.marquee { overflow: hidden; white-space: nowrap; border-block: 1px solid var(--rule); }
.marquee--alt { background: var(--surface); }
.marquee-track { display: flex; width: max-content; will-change: transform; }
.marquee-item { padding-inline: var(--s5); padding-block: var(--s3); font-size: var(--t-h1); font-weight: 700; line-height: 1.3; color: var(--ink); }
.marquee--alt .marquee-item { color: color-mix(in srgb, var(--ink) 55%, var(--ground)); }
.marquee-note { padding-inline: var(--s3); font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; }

@media (prefers-reduced-motion: reduce) {
  .marquee-track { transform: none; }
}
</style>
</head>
<body data-motion-number="16">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">16</p>
    <h1 class="dv-name">マーキー</h1>
    <p class="dv-ai">Marquee (scroll velocity)</p>
    <p class="dv-desc">流れ続け、スクロールで加速</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="marquee-zone">
    <div class="marquee-stick">
      <div class="marquee" data-dir="left">
        <div class="marquee-track">
          <span class="marquee-item">見出しが入ります</span>
          <span class="marquee-item">見出しが入ります</span>
          <span class="marquee-item">見出しが入ります</span>
          <span class="marquee-item">見出しが入ります</span>
        </div>
      </div>
      <p class="marquee-note">スクロールすると流れが速くなります</p>
      <div class="marquee marquee--alt" data-dir="right">
        <div class="marquee-track">
          <span class="marquee-item">本文のダミーです</span>
          <span class="marquee-item">本文のダミーです</span>
          <span class="marquee-item">本文のダミーです</span>
          <span class="marquee-item">本文のダミーです</span>
        </div>
      </div>
    </div>
  </section>
</main>

<footer class="dv-end">END — 16 マーキー</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 ---- */
// 帯の中身を 2 組に増やし、組の幅ぶん流れたら先頭へ戻す。
const bands = [...document.querySelectorAll('.marquee')].map((el) => {
  const track = el.querySelector('.marquee-track');
  track.append(...[...track.children].map((c) => c.cloneNode(true)));
  return { track, dir: el.dataset.dir === 'right' ? 1 : -1, x: 0 };
});

// 基本の速さ(60px/s)に、スクロールの速さで足した分を減衰させながら乗せる。
let lastY = window.scrollY;
let boost = 0;
let last = performance.now();
const frame = (now) => {
  const dt = Math.min(0.05, (now - last) / 1000);
  last = now;
  const dy = Math.abs(window.scrollY - lastY);
  lastY = window.scrollY;
  boost = boost * Math.pow(0.02, dt) + dy * 3;
  for (const b of bands) {
    const half = b.track.scrollWidth / 2;
    b.x += b.dir * (60 + boost) * dt;
    b.x = ((b.x % half) + half) % half - half;
    b.track.style.transform = `translate3d(${b.x.toFixed(2)}px, 0, 0)`;
  }
  requestAnimationFrame(frame);
};
if (!dvReduce) requestAnimationFrame(frame);
</script>
</body>
</html>