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

一式

LP の構成 35–39 — 区画のつなぎ方を決める演出

タイムラインからヒーロー縮小まで 5 種。区画の並べ方と切り替え方を決める動きで、LP の骨組みになる

5 枚

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

読む順

  1. 01 動き タイムライン タイムライン
  2. 02 動き 逆走カラム 逆走カラム
  3. 03 動き カーテンリビール カーテンリビール
  4. 04 動き バーの成長 バーの成長
  5. 05 動き ヒーロー縮小 ヒーロー縮小

この一式について

持ち帰る

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

全部入り

scroll-motion-lp-everything.md

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

依頼文

6.2 KB
# タイムライン
タイムライン — Timeline progress fill

縦のタイムラインを作る。段は 5 つで、各段は画面の高さの 40vh を占める。背景の細い線(幅 2px)の上に、同じ太さの線をアクセント色で重ね、その線を `transform: scaleY(var(--p))` で上から下へ伸ばす。--p は 0〜1 で、区画全体のスクロール量から受け取る。
線の先端が段の中央(区画の高さの (i + 0.5) / 5 の位置)を越えたら、その段の丸いノードに `.is-on` を付けて、白い地から塗りつぶしへ 0.7 秒で色を変える。段のノードは線の左側(375px では左端)に並べ、本文は線の右側に置く。
幅 720px 以上では線を中央に置いて段の本文を左右に交互に配し、375px では線を左端に寄せて一列にする。
CSS と素の JavaScript だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 線は --p に比例して伸び、先端より下の段は灯らない
- 段が灯るのは線が中央を越えた後だけで、先に灯らない
- 375px では線と段のノードを左端に寄せ、横スクロールを出さない
- 動きを減らす設定では線を全長で、全段を灯した状態で止める

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

---

# 逆走カラム
逆走カラム — Opposing-scroll columns

2 列のカードを並べ、左の列は上へ、右の列は下へ流れる。各列はカード 4 枚。
スクロール量 `--p`(0〜1)から、左の列は `translate3d(0, calc((var(--p) - 0.5) * -24vh), 0)`、右の列は同じ式の符号を反転して動かす。両列は 24vh の範囲を逆向きに移動する(±12vh)。
幅 600px 未満では 1 列に並べ、動きを止める。
CSS と素の JavaScript だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 左右の列は必ず逆向きに動く(一方が上がるとき、他方は下がる)
- 両列の移動量は同じ(±12vh)で、片方だけ大きく動かさない
- 動きを減らす設定では、両列を動かさずに並べて止める

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

---

# カーテンリビール
カーテンリビール — Curtain section reveal

全画面の区画を 3 つ、同じ位置に重ねて表示する演出を作ってください。区画はそれぞれ position: sticky; top: 0 の 100vh の箱で、z-index は後ろの区画ほど小さくします。区画を包む外側の区画は高さ 300vh とします。外側の進み具合 p(0〜1)を求め、1 つ目の区画は p が 0〜0.5 の間に上へ 100vh 持ち上がり、2 つ目の区画は p が 0.5〜1 の間に同じく持ち上がります。持ち上がる区画には影を落とし、同時に 4% ほど縮めます。その下から次の区画が現れます。CSS と素の JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 区画は 100vh、外側の区画は 300vh を保つ(区画の高さを変えると持ち上がる区切りがずれる)
- 先の区画ほど手前に出す(z-index を後ろほど小さく)。逆にすると次の区画が前に出て、持ち上がって見えない
- 持ち上がりは進み具合 p から決め、時間では動かさない
- 動きを減らす設定では区画を縦に並べ、持ち上がりを止める

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

---

# バーの成長
バーの成長 — Chart bars grow on view

月別の達成率を示す 6 本の棒グラフを作ってください。グラフが画面の下端から 50% 入ったら、棒が下から上へ伸び、先端の数値が 0% から最終値まで同時にカウントアップします。各棒は前の棒より 90ms 遅れて始まり、1 本の伸びは 0.7 秒、緩急は easeOut(1 − (1 − t)³)でつけます。最終値は 38%、52%、47%、68%、81%、94% で、最新の月だけアクセント色、残りは控えめな色で描きます。数値は表にも書き、JS が動かなくても読めるようにします。CSS と JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 数値は 0 から最終値へ一方向に増え、途中で戻さない
- 最終値は HTML と表に残し、JS が動かなくても読める
- 動きを減らす設定では、棒の全長と最終値を即表示する

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

---

# ヒーロー縮小
ヒーロー縮小 — Hero title shrinks into header

ページ冒頭に画面いっぱいの大見出しを置き、スクロールに合わせてヘッダーの位置へ縮めて収める演出を作ってください。外側の区画は高さ 300vh とし、その中に position: sticky; top: 0 の 100vh の箱を置きます。区画の上端が画面の上端に来たとき p=0、区画の下端が画面の下端に来たとき p=1 として進み具合 p(0〜1)を求めます。見出しは `transform: scale(calc(1 - var(--p) * 0.82))` で 1 から 0.18 まで縮め、同時に画面中央から上端へ移動させて、p=1 でヘッダーのブランド位置(左 24px、高さ 64px のヘッダーの上下中央)に着地させます。ヘッダーは固定の別部品として上端に置き、着地したときだけブランドの文字を表示します。CSS と素の JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 縮む見出しは pin 区画の中に置き、区画が終わるまでの間に着地を終える
- 拡大率は 1 から 0.18 の範囲に収め、ヘッダーの文字の大きさと一致させる
- 動きを減らす設定では見出しを動かさず、ヘッダーに収まった状態だけを表示する

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

仕様

460 B
# タイムライン
https://design-vault.pages.dev/ai/assets/motion-timeline-fill.json

---

# 逆走カラム
https://design-vault.pages.dev/ai/assets/motion-opposite-columns.json

---

# カーテンリビール
https://design-vault.pages.dev/ai/assets/motion-curtain-reveal.json

---

# バーの成長
https://design-vault.pages.dev/ai/assets/motion-bar-grow.json

---

# ヒーロー縮小
https://design-vault.pages.dev/ai/assets/motion-hero-shrink.json

ファイル

01 motion-timeline-fill/demo.html

9.7 KB

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

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>35 タイムライン — Timeline progress fill</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() のみ ---- */
.tl { position: relative; height: 200vh; }
.tl-track, .tl-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 11px;
  width: 2px;
}
.tl-track { background: var(--rule); }
.tl-fill {
  background: var(--accent);
  transform-origin: top;
  transform: scaleY(var(--p, 0));
}
.tl-step {
  position: relative;
  height: 40vh;
  display: grid;
  align-content: center;
  padding-inline-start: var(--s6);
}
.tl-body { display: grid; gap: var(--s2); }
.tl-node {
  position: absolute;
  left: 0;
  top: 50%;
  width: 24px;
  height: 24px;
  margin-top: -12px;
  border-radius: 50%;
  border: 2px solid var(--rule);
  background: var(--ground);
  transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.tl-node.is-on { background: var(--accent); border-color: var(--accent); }

@media (min-width: 720px) {
  .tl-track, .tl-fill { left: 50%; margin-left: -1px; }
  .tl-step { padding-inline-start: 0; grid-template-columns: 1fr 1fr; column-gap: var(--s6); }
  .tl-step .tl-body { grid-column: 1; }
  .tl-step:nth-child(even) .tl-body { grid-column: 2; }
  .tl-step:nth-child(odd) .tl-body { text-align: end; }
  .tl-node { left: 50%; margin-left: -12px; }
}

html:not(.js) .tl-fill { transform: none; }
@media (prefers-reduced-motion: reduce) {
  .tl-fill { transform: none; }
  .tl-node { transition: none; }
}
</style>
</head>
<body data-motion-number="35">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">35</p>
    <h1 class="dv-name">タイムライン</h1>
    <p class="dv-ai">Timeline progress fill</p>
    <p class="dv-desc">縦線が伸び、各段が灯る</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap">
    <ol class="tl" data-progress="through">
      <span class="tl-track" aria-hidden="true"></span>
      <span class="tl-fill" aria-hidden="true"></span>
      <li class="tl-step">
        <span class="tl-node" aria-hidden="true"></span>
        <div class="tl-body">
          <p class="dv-eyebrow">Step 01</p>
          <h2 class="dv-h2">見出しが入ります</h2>
          <p class="dv-lead">本文のダミーです。線が届いた段から灯ります。</p>
        </div>
      </li>
      <li class="tl-step">
        <span class="tl-node" aria-hidden="true"></span>
        <div class="tl-body">
          <p class="dv-eyebrow">Step 02</p>
          <h2 class="dv-h2">見出しが入ります</h2>
          <p class="dv-lead">本文のダミーです。段が増えても同じ線を進みます。</p>
        </div>
      </li>
      <li class="tl-step">
        <span class="tl-node" aria-hidden="true"></span>
        <div class="tl-body">
          <p class="dv-eyebrow">Step 03</p>
          <h2 class="dv-h2">見出しが入ります</h2>
          <p class="dv-lead">本文のダミーです。中ほどの段です。</p>
        </div>
      </li>
      <li class="tl-step">
        <span class="tl-node" aria-hidden="true"></span>
        <div class="tl-body">
          <p class="dv-eyebrow">Step 04</p>
          <h2 class="dv-h2">見出しが入ります</h2>
          <p class="dv-lead">本文のダミーです。最後から二番目の段です。</p>
        </div>
      </li>
      <li class="tl-step">
        <span class="tl-node" aria-hidden="true"></span>
        <div class="tl-body">
          <p class="dv-eyebrow">Step 05</p>
          <h2 class="dv-h2">見出しが入ります</h2>
          <p class="dv-lead">本文のダミーです。最後の段に届くと、線が全長になります。</p>
        </div>
      </li>
    </ol>
  </div>
</main>

<footer class="dv-end">END — 35 タイムライン</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 ---- */
// 線の先端が段の中央((i + 0.5) / 5)を越えたら、その段のノードを灯す。
dvProgress((el, p) => {
  el.querySelectorAll('.tl-node').forEach((node, i) => {
    node.classList.toggle('is-on', dvReduce || p >= (i + 0.5) / 5);
  });
});
</script>
</body>
</html>

02 motion-opposite-columns/demo.html

8.2 KB

/designs/motion-opposite-columns/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>36 逆走カラム — Opposing-scroll columns</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); }
.olc { padding-block: var(--s5); }
.olc-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s3); align-items: start; }
.olc-col { display: grid; gap: var(--s3); will-change: transform; }
.olc-col--up { transform: translate3d(0, calc((var(--p, 0.5) - 0.5) * -24vh), 0); }
.olc-col--down { transform: translate3d(0, calc((var(--p, 0.5) - 0.5) * 24vh), 0); }

@media (max-width: 599px) {
  .olc-grid { grid-template-columns: minmax(0, 1fr); }
  .olc-col { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .olc-col { transform: none; }
}
</style>
</head>
<body data-motion-number="36">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">36</p>
    <h1 class="dv-name">逆走カラム</h1>
    <p class="dv-ai">Opposing-scroll columns</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">本文のダミーです。このあとの 2 つの列が、逆向きに流れます。</p>
    </section>
  </div>

  <section class="olc" data-progress="through">
    <div class="dv-wrap olc-grid">
      <div class="olc-col olc-col--up">
        <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 class="dv-card"><div class="dv-ph dv-ph--alt"></div></div>
      </div>
      <div class="olc-col olc-col--down">
        <div class="dv-card"><div class="dv-ph dv-ph--ink"></div></div>
        <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>
  </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 — 36 逆走カラム</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-curtain-reveal/demo.html

9.2 KB

/designs/motion-curtain-reveal/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>37 カーテンリビール — Curtain section reveal</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() のみ ---- */
.curtain-zone { position: relative; }
.curtain-sec {
  position: relative;
  height: 100vh;
  display: grid;
  align-content: center;
  padding-inline: var(--s3);
  background: var(--ground);
}
.curtain-sec:nth-child(1) { background: var(--surface); }
.curtain-sec:nth-child(2) { background: var(--accent-soft); }
.curtain-sec .dv-wrap { display: grid; gap: var(--s3); }
.curtain-sec .dv-ph { aspect-ratio: auto; height: min(28vh, 240px); }

/* 3 つの区画を同じ位置に重ね、先の区画ほど手前に出す。JS が無いときは縦に並べる。 */
.js .curtain-zone { height: 300vh; }
.js .curtain-sec {
  position: sticky;
  top: 0;
  transform: translate3d(0, calc(var(--q, 0) * -100%), 0) scale(calc(1 - var(--q, 0) * .04));
  transform-origin: 50% 0;
  /* 影は持ち上がっている途中だけ濃く、切れたら消す(--arc は 0→1→0) */
  --arc: calc(var(--q, 0) * (1 - var(--q, 0)) * 4);
  box-shadow: 0 calc(var(--arc) * 32px) calc(var(--arc) * 64px) var(--shade);
  will-change: transform;
}
.js .curtain-sec + .curtain-sec { margin-top: -100vh; }
.js .curtain-sec:nth-child(1) { z-index: 3; }
.js .curtain-sec:nth-child(2) { z-index: 2; }
.js .curtain-sec:nth-child(3) { z-index: 1; }

@media (prefers-reduced-motion: reduce) {
  .js .curtain-zone { height: auto; }
  .js .curtain-sec:nth-child(n) { position: relative; height: auto; padding-block: var(--s7); transform: none; box-shadow: none; margin-top: 0; z-index: auto; }
}
</style>
</head>
<body data-motion-number="37">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">37</p>
    <h1 class="dv-name">カーテンリビール</h1>
    <p class="dv-ai">Curtain section reveal</p>
    <p class="dv-desc">前の画面が持ち上がり次が現れる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="curtain-zone" data-progress="pin">
    <section class="curtain-sec">
      <div class="dv-wrap">
        <p class="dv-eyebrow">Section 01</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <p class="dv-lead">本文のダミーです。この画面が持ち上がると、次の画面が現れます。</p>
        <div class="dv-ph"></div>
      </div>
    </section>
    <section class="curtain-sec">
      <div class="dv-wrap">
        <p class="dv-eyebrow">Section 02</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <p class="dv-lead">本文のダミーです。持ち上がった画面の下から、次の区画が見えてきます。</p>
        <div class="dv-ph dv-ph--alt"></div>
      </div>
    </section>
    <section class="curtain-sec">
      <div class="dv-wrap">
        <p class="dv-eyebrow">Section 03</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <p class="dv-lead">本文のダミーです。最後の区画は、そのまま残ります。</p>
        <div class="dv-ph dv-ph--ink"></div>
      </div>
    </section>
  </div>
</main>

<footer class="dv-end">END — 37 カーテンリビール</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 curtainSecs = [...document.querySelectorAll('.curtain-sec')];
// 区画 1 は p の前半、区画 2 は後半で持ち上がる。区画 3 は最後まで残る。
dvProgress((el, p) => {
  curtainSecs.slice(0, -1).forEach((s, k) => {
    const q = Math.min(1, Math.max(0, p * 2 - k));
    s.style.setProperty('--q', q.toFixed(4));
  });
});
</script>
</body>
</html>

04 motion-bar-grow/demo.html

11.2 KB

/designs/motion-bar-grow/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>38 バーの成長 — Chart bars grow on view</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() のみ ---- */
.intro { min-height: 90vh; display: grid; align-content: center; gap: var(--s3); }
.outro { min-height: 60vh; display: grid; align-content: center; }
.plot {
  position: relative;
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  column-gap: var(--s2);
  padding-top: var(--s5);
  margin-bottom: var(--s6);
  border-bottom: 1px solid var(--muted);
}
.col { position: relative; height: 300px; --h: 1; }
.js .col { --h: 0; }
.bar {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: min(100%, 56px);
  height: calc(var(--v) * 1%);
  border-radius: 4px 4px 0 0;
  background: var(--muted);
  transform-origin: 50% 100%;
  transform: translateX(-50%) scaleY(var(--h));
}
.val {
  position: absolute;
  left: 50%;
  bottom: calc(var(--v) * 1% + 6px);
  white-space: nowrap;
  font-size: var(--t-lead);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
  transform: translate(-50%, calc((1 - var(--h)) * var(--v) * 3px));
}
.col.is-last .bar { background: var(--accent); }
.col.is-last .val { color: var(--accent); }
.axis {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: var(--s2);
  text-align: center;
  font-size: var(--t-label);
  color: var(--muted);
}
.dv-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.dv-table caption { text-align: left; padding-bottom: var(--s2); }
.dv-table th, .dv-table td { padding: var(--s2) var(--s3); border-bottom: 1px solid var(--rule); text-align: left; }
.dv-table th:last-child, .dv-table td:last-child { text-align: right; }

@media (prefers-reduced-motion: reduce) {
  .bar, .val { transition: none; }
}
</style>
</head>
<body data-motion-number="38">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">38</p>
    <h1 class="dv-name">バーの成長</h1>
    <p class="dv-ai">Chart bars grow on view</p>
    <p class="dv-desc">グラフの棒が伸び、数値が付く</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="dv-wrap intro">
    <p class="dv-eyebrow">Monthly</p>
    <h2 class="dv-h2">見出しが入ります</h2>
    <p class="dv-lead">本文のダミーです。グラフは画面に入ると一度だけ伸びます。</p>
  </section>

  <section class="dv-wrap">
    <p class="dv-eyebrow">Chart</p>
    <h2 class="dv-h2" style="margin: var(--s3) 0 var(--s5)">月別の達成率</h2>
    <figure class="plot">
      <div class="col" style="--v: 38"><div class="bar"></div><span class="val"><span data-count="38">38</span>%</span><span class="axis">1月</span></div>
      <div class="col" style="--v: 52"><div class="bar"></div><span class="val"><span data-count="52">52</span>%</span><span class="axis">2月</span></div>
      <div class="col" style="--v: 47"><div class="bar"></div><span class="val"><span data-count="47">47</span>%</span><span class="axis">3月</span></div>
      <div class="col" style="--v: 68"><div class="bar"></div><span class="val"><span data-count="68">68</span>%</span><span class="axis">4月</span></div>
      <div class="col" style="--v: 81"><div class="bar"></div><span class="val"><span data-count="81">81</span>%</span><span class="axis">5月</span></div>
      <div class="col is-last" style="--v: 94"><div class="bar"></div><span class="val"><span data-count="94">94</span>%</span><span class="axis">6月</span></div>
    </figure>

    <table class="dv-table">
      <caption class="dv-eyebrow">表で読む</caption>
      <thead><tr><th scope="col">月</th><th scope="col">達成率</th></tr></thead>
      <tbody>
        <tr><th scope="row">1月</th><td>38%</td></tr>
        <tr><th scope="row">2月</th><td>52%</td></tr>
        <tr><th scope="row">3月</th><td>47%</td></tr>
        <tr><th scope="row">4月</th><td>68%</td></tr>
        <tr><th scope="row">5月</th><td>81%</td></tr>
        <tr><th scope="row">6月</th><td>94%</td></tr>
      </tbody>
    </table>
  </section>

  <section class="dv-wrap outro">
    <div class="dv-card">
      <p class="dv-lead">次の見出しが入ります。</p>
    </div>
  </section>
</main>

<footer class="dv-end">END — 38 バーの成長</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 plot = document.querySelector('.plot');
const cols = [...document.querySelectorAll('.col')];
const STEP = 90;
const DUR = 700;
const fmt = (el, v) => { el.textContent = Math.round(v).toLocaleString('ja-JP'); };
const ease = (t) => 1 - Math.pow(1 - t, 3);

/* 棒と数値は同じ 1 つの値(--h)から作る。棒の先端に数値が付いたまま伸びる。 */
const grow = () => {
  const t0 = performance.now();
  const frame = (now) => {
    let running = false;
    cols.forEach((col, i) => {
      const t = Math.min(1, Math.max(0, (now - t0 - i * STEP) / DUR));
      const e = ease(t);
      col.style.setProperty('--h', e.toFixed(4));
      const val = col.querySelector('[data-count]');
      fmt(val, Number(val.dataset.count) * e);
      if (t < 1) running = true;
    });
    if (running) requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);
};

if (dvReduce || !('IntersectionObserver' in window)) {
  cols.forEach((col) => col.style.setProperty('--h', 1));
} else {
  cols.forEach((col) => fmt(col.querySelector('[data-count]'), 0));
  const io = new IntersectionObserver((entries) => {
    if (entries.some((e) => e.isIntersecting)) {
      io.disconnect();
      grow();
    }
  }, { threshold: 0.5 });
  io.observe(plot);
}
</script>
</body>
</html>

05 motion-hero-shrink/demo.html

9.1 KB

/designs/motion-hero-shrink/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>39 ヒーロー縮小 — Hero title shrinks into header</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 { --hero: min(28vw, 200px); }
.stack { display: grid; gap: var(--s6); }
.js .hd-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 10; height: 64px; display: flex; align-items: center; justify-content: space-between; gap: var(--s3); padding-inline: var(--s4); background: color-mix(in srgb, var(--ground) 92%, transparent); border-bottom: 1px solid var(--rule); }
.hd-brand { font-size: calc(var(--hero) * 0.18); font-weight: 700; line-height: 1.15; color: var(--ink); }
.js .hd-brand { opacity: 0; }
.js .hd-bar.is-landed .hd-brand { opacity: 1; }
.hd-bar nav ul { display: flex; gap: var(--s4); font-size: var(--t-label); color: var(--muted); }
.shrink-zone { height: 300vh; }
.shrink-stick { position: sticky; top: 0; height: 100vh; overflow: hidden; }
.shrink-title {
  position: absolute;
  top: 50%;
  left: var(--s4);
  margin-top: -0.575em;
  font-size: var(--hero);
  font-weight: 700;
  line-height: 1.15;
  color: var(--ink);
  white-space: nowrap;
  transform-origin: 0 0;
  transform:
    translateY(calc(var(--p) * (32px - 50vh + 0.4715em)))
    scale(calc(1 - var(--p) * 0.82));
}
.shrink-lead {
  position: absolute;
  left: var(--s4);
  right: var(--s4);
  bottom: var(--s5);
  color: var(--muted);
  opacity: calc(1 - var(--p) * 2);
}

@media (prefers-reduced-motion: reduce) {
  .hd-bar { transition: none; }
  .shrink-zone { height: auto; }
  .shrink-stick { position: static; height: auto; padding-block: var(--s7); }
  .shrink-title, .shrink-lead { display: none; }
}
</style>
</head>
<body data-motion-number="39">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">39</p>
    <h1 class="dv-name">ヒーロー縮小</h1>
    <p class="dv-ai">Hero title shrinks into header</p>
    <p class="dv-desc">大見出しが縮んでヘッダーに収まる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <header class="hd-bar">
    <p class="hd-brand">見出し</p>
    <nav aria-label="主要メニュー">
      <ul>
        <li>製品</li>
        <li>事例</li>
        <li>料金</li>
      </ul>
    </nav>
  </header>

  <div class="dv-wrap stack">
    <section class="dv-card" data-reveal>
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。スクロールすると、上の大見出しが縮んでヘッダーへ収まります。</p>
    </section>
  </div>

  <section class="shrink-zone" data-progress="pin">
    <div class="shrink-stick">
      <p class="shrink-title">見出し</p>
      <p class="shrink-lead">本文のダミーです。見出しが入ります。</p>
    </div>
  </section>

  <div class="dv-wrap stack">
    <section class="dv-card" data-reveal>
      <p class="dv-eyebrow">Section 2</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。縮み終えた見出しはヘッダーに残ります。</p>
    </section>
    <div class="dv-ph" data-reveal></div>
  </div>
</main>

<footer class="dv-end">END — 39 ヒーロー縮小</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 hdBar = document.querySelector('.hd-bar');
if (dvReduce) hdBar.classList.add('is-landed');
dvReveal();
dvProgress((el, p) => { if (!dvReduce) hdBar.classList.toggle('is-landed', p >= 0.999); });
</script>
</body>
</html>