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

一式

出てくる動き 01–08 — 画面に入ったときの演出

フェードインからカウントアップまで 8 種。どれも「画面に入ったら一度だけ再生する」動きで、同じ配色・同じ舞台に載せてあるので、違いは動き方だけ

8 枚

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

読む順

  1. 01 動き フェードイン フェードイン
  2. 02 動き スライドイン スライドイン
  3. 03 動き スタッガー スタッガー
  4. 04 動き テキストリビール テキストリビール
  5. 05 動き マスクリビール マスクリビール
  6. 06 動き ブラーイン ブラーイン
  7. 07 動き タイプライター タイプライター
  8. 08 動き カウントアップ カウントアップ

この一式について

持ち帰る

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

全部入り

scroll-motion-entrances-everything.md

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

依頼文

8.8 KB
# フェードイン
フェードイン — Scroll-triggered fade-in

画面に入った要素を、透明度 0 → 1 と、下から 24px の位置(translateY(24px) → 0)からの移動で表示する。
要素の上端が画面下端から 8% 上に入ったとき(threshold 0.2)に一度だけ再生し、長さは 0.7 秒、イージングは cubic-bezier(.2, .7, .2, 1) にする。
見出しのカードと画像の 2 つを縦に並べ、スクロールのたびに同じ動きが一度ずつ見えるようにする。
動きを減らす設定(prefers-reduced-motion: reduce)では、最初から完成した状態で表示する。
CSS と IntersectionObserver だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 一度表示したら、戻っても消さない
- 移動は 24px 以内に抑え、必ず透明度と組み合わせる
- 動きを減らす設定では、完成した状態を動かさずに見せる

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

---

# スライドイン
スライドイン — Slide-in

要素を左右交互に、横から 64px 外の位置からすべり込ませる。奇数番目は左から(translateX(-64px) → 0)、偶数番目は右から(translateX(64px) → 0)。
透明度 0 → 1 を同時に変え、長さは 0.7 秒、イージングは cubic-bezier(.2, .7, .2, 1) にする。
各要素は画面下端から 8% の位置に入ったとき一度だけ再生し、カードと画像を交互に 5 つ並べる。
はみ出した部分は overflow-x: clip で隠し、横スクロールバーを出さない。
CSS と IntersectionObserver だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 左右の交互を崩さない(奇数は左、偶数は右)
- 移動量は 64px に留め、ページ幅を超えて横スクロールバーを出さない
- 動きを減らす設定では、位置を戻して動かずに表示する

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

---

# スタッガー
スタッガー — Stagger animation

カードを 6 枚、幅 900px 以上では 3 列、600px 以上では 2 列、それより狭いときは 1 列で並べ、それぞれに --i(0〜5)を与える。
画面に入ったら各カードが透明度 0 → 1 と、下から 24px の位置(translateY(24px) → 0)からの移動で表示される。
transition-delay を calc(var(--i) * 90ms) にして順番をずらし、各カードの動きは 0.7 秒、イージングは cubic-bezier(.2, .7, .2, 1) にする。
6 枚目の開始は 1 枚目から 450ms 遅れる。
CSS と IntersectionObserver だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 順番は --i の昇順(左上から右下へ)を守る
- 遅延は 90ms 刻みに固定し、6 枚目の開始を 450ms 以内に収める
- 動きを減らす設定では遅延も動きも無しに、6 枚すべてを即座に表示する

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

---

# テキストリビール
テキストリビール — Text reveal (Split text)

見出しの文字を 1 字ずつ span に割り、各字を下(translateY(120%))から元の位置(translateY(0))へせり上がらせる。
字と字の遅延は calc(var(--i) * 45ms)、各字の動きは 0.7 秒、イージングは cubic-bezier(.2, .7, .2, 1) にする。
見出しは 1 行ずつ別の要素にし、各行を overflow: hidden の箱で切って下から出てくるように見せる。
3 行の見出しを、画面の中央(threshold 0.5)に入ったとき一行ずつ再生する。
CSS と IntersectionObserver と素の JavaScript だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 一行は一つの要素にし、overflow: hidden で切る(行をまたがせない)
- 字の順は左から右、遅延は字ごとに 45ms 刻みにする
- 元の文は aria-label に残し、字の span は読み上げから外す

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

---

# マスクリビール
マスクリビール — Clip-path reveal

画面下に並んだ 4 枚の画像が、それぞれ幕が開くように現れる演出を作ってください。画像は画面の下端から 20% 入ったところで一度だけ表示します。1 枚目は左から右へ、2 枚目は右から左へ、3 枚目は下から上へ開きます。開き方は clip-path を inset(0 100% 0 0)、inset(0 0 0 100%)、inset(100% 0 0 0) から inset(0) へ 0.7 秒、cubic-bezier(.2, .7, .2, 1) で動かします。画像の中身は拡大も移動もさせず、動かすのは clip-path だけにしてください。CSS と IntersectionObserver だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 切り抜くのは中の画像で、表示を見張る要素は切り抜かない(切り抜くと画面に入ったと判定されない)
- 画像は clip-path だけで開き、中身の拡大や移動をしない
- 一度開いたら、戻っても閉じない
- 動きを減らす設定では、最初から全部開いた状態で見せる

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

---

# ブラーイン
ブラーイン — Blur-in reveal

画面に入ったカード 2 枚と画像 2 枚が、ぼけた状態からくっきり見える状態へ変わる演出を作ってください。各要素は画面の下端から 20% 入ったところで一度だけ動きます。初期状態は filter: blur(16px) と opacity: 0 で、完成状態は filter: none と opacity: 1 です。両方を 0.7 秒、cubic-bezier(.2, .7, .2, 1) で遷移させます。要素の位置は動かさず、ぼかしと透明度だけを変えてください。CSS と IntersectionObserver だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 一度くっきりしたら、戻ってもぼかさない
- ぼかしと透明度だけを変え、位置やサイズは動かさない
- 動きを減らす設定では、ぼかしを使わず最初から表示する

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

---

# タイプライター
タイプライター — Typewriter effect

画面に入った順に、3 行の文章を 1 文字ずつ打ち込む演出を作ってください。1 行目は見出し(32px、太字)、2 行目は本文(20px、薄い色)、3 行目は見出しです。各行は画面の下端から 50% 入ったところで打ち始め、1 文字ごとに 80 ミリ秒あけて文字を足します。前の行を打ち終えてから次の行を打ちます。打っている行の末尾には、幅 0.08em の点滅するカーソルを 1 秒周期(steps(1))で表示し、打ち終えたら消します。JS で textContent を 1 文字ずつ書き換え、CSS と IntersectionObserver だけで動かし、ライブラリは使わないでください。

外すと崩れる点:
- 行は画面に入った順に打ち、前の行が終わるまで次を始めない
- 全文は HTML に残し、JS が動かない場合も読める
- 動きを減らす設定では全文を即表示し、カーソルは出さない

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

---

# カウントアップ
カウントアップ — Count-up animation

4 つの数値が、それぞれ画面に入ったときに 0 から最終値まで増えるカウントアップを作ってください。最終値は 1,280、98.6(%)、42、3.2(秒)です。1 つの数値は 1.6 秒かけて増え、緩急は easeOut(1 − (1 − t)³)でつけます。requestAnimationFrame で毎フレーム値を書き換え、桁の揃う font-variant-numeric: tabular-nums を使います。数値の下には高さ 4px の棒を置き、同じ 1.6 秒で transform の scaleX を 0 から 1 へ伸ばしてください。各区画は画面の下端から 50% 入ったところで一度だけ開始します。CSS と IntersectionObserver だけで実装し、ライブラリは使わないでください。

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

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

仕様

719 B
# フェードイン
https://design-vault.pages.dev/ai/assets/motion-fade-in.json

---

# スライドイン
https://design-vault.pages.dev/ai/assets/motion-slide-in.json

---

# スタッガー
https://design-vault.pages.dev/ai/assets/motion-stagger.json

---

# テキストリビール
https://design-vault.pages.dev/ai/assets/motion-text-reveal.json

---

# マスクリビール
https://design-vault.pages.dev/ai/assets/motion-mask-reveal.json

---

# ブラーイン
https://design-vault.pages.dev/ai/assets/motion-blur-in.json

---

# タイプライター
https://design-vault.pages.dev/ai/assets/motion-typewriter.json

---

# カウントアップ
https://design-vault.pages.dev/ai/assets/motion-count-up.json

ファイル

01 motion-fade-in/demo.html

7.6 KB

/designs/motion-fade-in/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>01 フェードイン — Scroll-triggered fade-in</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

  --font: "Zen Kaku Gothic New", "Hiragino Sans", "Noto Sans JP", "Yu Gothic", system-ui, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  --t-label: 13px;
  --t-body: 16px;
  --t-lead: 20px;
  --t-h2: 32px;
  --t-h1: clamp(40px, 7vw, 72px);

  --s1: 4px;
  --s2: 8px;
  --s3: 16px;
  --s4: 24px;
  --s5: 40px;
  --s6: 64px;
  --s7: 104px;

  --radius: 12px;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --dur: .7s;
  --measure: 1080px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--t-body);
  line-height: 1.7;
  font-feature-settings: "palt";
  overflow-x: hidden;
}
h1, h2, h3, p, ul, ol, figure { margin: 0; padding: 0; }
ul, ol { list-style: none; }
.dv-wrap { width: min(100% - 2 * var(--s4), var(--measure)); margin-inline: auto; }
.dv-label {
  min-height: 72vh;
  display: grid;
  align-content: center;
  gap: var(--s3);
  padding-block: var(--s6);
  border-bottom: 1px solid var(--rule);
}
.dv-num { font-family: var(--mono); font-size: var(--t-lead); font-weight: 700; color: var(--accent); letter-spacing: .04em; }
.dv-name { font-size: var(--t-h1); font-weight: 700; line-height: 1.15; letter-spacing: .01em; }
.dv-ai { font-family: var(--mono); font-size: var(--t-lead); color: var(--muted); }
.dv-desc { font-size: var(--t-lead); color: var(--ink); }
.dv-hint { font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; margin-top: var(--s4); }
.dv-stage { padding-block: var(--s7); }
.dv-h2 { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; }
.dv-lead { font-size: var(--t-lead); color: var(--muted); }
.dv-eyebrow { font-family: var(--mono); font-size: var(--t-label); color: var(--accent); letter-spacing: .12em; text-transform: uppercase; }
.dv-card { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); padding: var(--s4); }
.dv-ph {
  border-radius: var(--radius);
  background: linear-gradient(135deg, var(--accent-soft), var(--accent-mid) 60%, var(--accent));
  aspect-ratio: 16 / 9;
}
.dv-ph--alt { background: linear-gradient(200deg, var(--surface), var(--accent-soft) 55%, var(--accent-mid)); }
.dv-ph--ink { background: linear-gradient(160deg, var(--ink), color-mix(in srgb, var(--ink) 55%, var(--accent))); }
.dv-end {
  min-height: 60vh;
  display: grid;
  place-items: center;
  border-top: 1px solid var(--rule);
  color: var(--muted);
  font-family: var(--mono);
  font-size: var(--t-label);
  letter-spacing: .12em;
}
/* dv-motion-base:end */

/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.stack { display: grid; gap: var(--s6); }
.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease); }
.js [data-reveal].is-in { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal] { opacity: 1; transform: none; transition: none; }
}
</style>
</head>
<body data-motion-number="01">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">01</p>
    <h1 class="dv-name">フェードイン</h1>
    <p class="dv-ai">Scroll-triggered fade-in</p>
    <p class="dv-desc">画面に入ったらふわっと表示</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap stack">
    <section class="dv-card" data-reveal>
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。画面に入った時点で一度だけ表示されます。</p>
    </section>
    <div class="dv-ph" data-reveal></div>
    <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 dv-ph--alt" data-reveal></div>
    <section class="dv-card" data-reveal>
      <p class="dv-eyebrow">Section 3</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。画面に入った時点で一度だけ表示されます。</p>
    </section>
    <div class="dv-ph dv-ph--ink" data-reveal></div>
  </div>
</main>

<footer class="dv-end">END — 01 フェードイン</footer>

<script>
/* dv-motion-helpers:start */
const dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.documentElement.classList.add('js');

/**
 * 画面に入った要素へ .is-in を付ける。
 * data-reveal="repeat" の要素は画面を出たら外す(何度でも再生)。
 */
function dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {
  const els = [...document.querySelectorAll(selector)];
  if (dvReduce || !('IntersectionObserver' in window)) {
    els.forEach((el) => el.classList.add('is-in'));
    return;
  }
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (e.isIntersecting) {
        e.target.classList.add('is-in');
        if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);
      } else if (e.target.dataset.reveal === 'repeat') {
        e.target.classList.remove('is-in');
      }
    }
  }, { threshold, rootMargin });
  els.forEach((el) => io.observe(el));
}

/**
 * スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。
 *   data-progress="through" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1
 *   data-progress="pin"     : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1
 *                             (中に position: sticky の箱を持つ、背の高い区画用)
 *   data-progress="page"    : ページ全体のスクロール量
 * onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。
 */
function dvProgress(onUpdate) {
  const els = [...document.querySelectorAll('[data-progress]')];
  const clamp = (n) => Math.min(1, Math.max(0, n));
  let queued = false;
  const measure = () => {
    queued = false;
    const vh = window.innerHeight;
    for (const el of els) {
      const r = el.getBoundingClientRect();
      let p;
      if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;
      else if (el.dataset.progress === 'page') {
        const max = document.documentElement.scrollHeight - vh;
        p = max > 0 ? window.scrollY / max : 0;
      } else p = (vh - r.top) / (vh + r.height);
      p = clamp(p);
      el.style.setProperty('--p', p.toFixed(4));
      if (onUpdate) onUpdate(el, p);
    }
  };
  const request = () => {
    if (!queued) { queued = true; requestAnimationFrame(measure); }
  };
  addEventListener('scroll', request, { passive: true });
  addEventListener('resize', request);
  measure();
}
/* dv-motion-helpers:end */

/* ---- この演出だけの JS ---- */
dvReveal();
</script>
</body>
</html>

02 motion-slide-in/demo.html

7.8 KB

/designs/motion-slide-in/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>02 スライドイン — Slide-in</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); }
.dv-stage { overflow-x: clip; }
.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease); }
.js [data-reveal].is-in { opacity: 1; transform: none; }
.stack > [data-slide] { width: min(100%, 720px); }
.stack > [data-slide="left"] { justify-self: start; }
.stack > [data-slide="right"] { justify-self: end; }
.js [data-slide="left"] { transform: translateX(-64px); }
.js [data-slide="right"] { transform: translateX(64px); }

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal] { opacity: 1; transform: none; transition: none; }
}
</style>
</head>
<body data-motion-number="02">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">02</p>
    <h1 class="dv-name">スライドイン</h1>
    <p class="dv-ai">Slide-in</p>
    <p class="dv-desc">横からすべり込む</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap stack">
    <section class="dv-card" data-reveal data-slide="left">
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。左から入ります。</p>
    </section>
    <div class="dv-ph" data-reveal data-slide="right"></div>
    <section class="dv-card" data-reveal data-slide="left">
      <p class="dv-eyebrow">Section 2</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。左から入ります。</p>
    </section>
    <div class="dv-ph dv-ph--alt" data-reveal data-slide="right"></div>
    <section class="dv-card" data-reveal data-slide="left">
      <p class="dv-eyebrow">Section 3</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。左から入ります。</p>
    </section>
  </div>
</main>

<footer class="dv-end">END — 02 スライドイン</footer>

<script>
/* dv-motion-helpers:start */
const dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.documentElement.classList.add('js');

/**
 * 画面に入った要素へ .is-in を付ける。
 * data-reveal="repeat" の要素は画面を出たら外す(何度でも再生)。
 */
function dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {
  const els = [...document.querySelectorAll(selector)];
  if (dvReduce || !('IntersectionObserver' in window)) {
    els.forEach((el) => el.classList.add('is-in'));
    return;
  }
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (e.isIntersecting) {
        e.target.classList.add('is-in');
        if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);
      } else if (e.target.dataset.reveal === 'repeat') {
        e.target.classList.remove('is-in');
      }
    }
  }, { threshold, rootMargin });
  els.forEach((el) => io.observe(el));
}

/**
 * スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。
 *   data-progress="through" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1
 *   data-progress="pin"     : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1
 *                             (中に position: sticky の箱を持つ、背の高い区画用)
 *   data-progress="page"    : ページ全体のスクロール量
 * onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。
 */
function dvProgress(onUpdate) {
  const els = [...document.querySelectorAll('[data-progress]')];
  const clamp = (n) => Math.min(1, Math.max(0, n));
  let queued = false;
  const measure = () => {
    queued = false;
    const vh = window.innerHeight;
    for (const el of els) {
      const r = el.getBoundingClientRect();
      let p;
      if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;
      else if (el.dataset.progress === 'page') {
        const max = document.documentElement.scrollHeight - vh;
        p = max > 0 ? window.scrollY / max : 0;
      } else p = (vh - r.top) / (vh + r.height);
      p = clamp(p);
      el.style.setProperty('--p', p.toFixed(4));
      if (onUpdate) onUpdate(el, p);
    }
  };
  const request = () => {
    if (!queued) { queued = true; requestAnimationFrame(measure); }
  };
  addEventListener('scroll', request, { passive: true });
  addEventListener('resize', request);
  measure();
}
/* dv-motion-helpers:end */

/* ---- この演出だけの JS ---- */
dvReveal();
</script>
</body>
</html>

03 motion-stagger/demo.html

8.3 KB

/designs/motion-stagger/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>03 スタッガー — Stagger 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() のみ ---- */
.tall { min-height: 200vh; display: grid; align-content: center; }
.cards { display: grid; gap: var(--s4); grid-template-columns: minmax(0, 1fr); }
@media (min-width: 600px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 900px) { .cards { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease); }
.js [data-reveal].is-in { opacity: 1; transform: none; }
.js .cards [data-reveal] { transition-delay: calc(var(--i) * 90ms); }

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal] { opacity: 1; transform: none; transition: none; }
}
</style>
</head>
<body data-motion-number="03">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">03</p>
    <h1 class="dv-name">スタッガー</h1>
    <p class="dv-ai">Stagger animation</p>
    <p class="dv-desc">時間差で順番に表示</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage tall">
  <div class="dv-wrap cards">
    <article class="dv-card" data-reveal style="--i: 0">
      <p class="dv-eyebrow">Card 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。</p>
    </article>
    <article class="dv-card" data-reveal style="--i: 1">
      <p class="dv-eyebrow">Card 2</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。</p>
    </article>
    <article class="dv-card" data-reveal style="--i: 2">
      <p class="dv-eyebrow">Card 3</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。</p>
    </article>
    <article class="dv-card" data-reveal style="--i: 3">
      <p class="dv-eyebrow">Card 4</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。</p>
    </article>
    <article class="dv-card" data-reveal style="--i: 4">
      <p class="dv-eyebrow">Card 5</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。</p>
    </article>
    <article class="dv-card" data-reveal style="--i: 5">
      <p class="dv-eyebrow">Card 6</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。</p>
    </article>
  </div>
</main>

<footer class="dv-end">END — 03 スタッガー</footer>

<script>
/* dv-motion-helpers:start */
const dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.documentElement.classList.add('js');

/**
 * 画面に入った要素へ .is-in を付ける。
 * data-reveal="repeat" の要素は画面を出たら外す(何度でも再生)。
 */
function dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {
  const els = [...document.querySelectorAll(selector)];
  if (dvReduce || !('IntersectionObserver' in window)) {
    els.forEach((el) => el.classList.add('is-in'));
    return;
  }
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (e.isIntersecting) {
        e.target.classList.add('is-in');
        if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);
      } else if (e.target.dataset.reveal === 'repeat') {
        e.target.classList.remove('is-in');
      }
    }
  }, { threshold, rootMargin });
  els.forEach((el) => io.observe(el));
}

/**
 * スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。
 *   data-progress="through" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1
 *   data-progress="pin"     : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1
 *                             (中に position: sticky の箱を持つ、背の高い区画用)
 *   data-progress="page"    : ページ全体のスクロール量
 * onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。
 */
function dvProgress(onUpdate) {
  const els = [...document.querySelectorAll('[data-progress]')];
  const clamp = (n) => Math.min(1, Math.max(0, n));
  let queued = false;
  const measure = () => {
    queued = false;
    const vh = window.innerHeight;
    for (const el of els) {
      const r = el.getBoundingClientRect();
      let p;
      if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;
      else if (el.dataset.progress === 'page') {
        const max = document.documentElement.scrollHeight - vh;
        p = max > 0 ? window.scrollY / max : 0;
      } else p = (vh - r.top) / (vh + r.height);
      p = clamp(p);
      el.style.setProperty('--p', p.toFixed(4));
      if (onUpdate) onUpdate(el, p);
    }
  };
  const request = () => {
    if (!queued) { queued = true; requestAnimationFrame(measure); }
  };
  addEventListener('scroll', request, { passive: true });
  addEventListener('resize', request);
  measure();
}
/* dv-motion-helpers:end */

/* ---- この演出だけの JS ---- */
dvReveal();
</script>
</body>
</html>

04 motion-text-reveal/demo.html

8.1 KB

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

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>04 テキストリビール — Text reveal (Split text)</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() のみ ---- */
.beat { min-height: 90vh; display: grid; align-content: center; gap: var(--s4); }
.dv-split-line { font-size: var(--t-h1); font-weight: 700; line-height: 1.25; }
.js [data-split] { overflow: hidden; padding-bottom: .15em; margin-bottom: -.15em; }
.js [data-split] .ch { display: inline-block; transform: translateY(120%); transition: transform var(--dur) var(--ease); transition-delay: calc(var(--i) * 45ms); }
.js [data-split].is-in .ch { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .js [data-split] .ch { transform: none; transition: none; }
}
</style>
</head>
<body data-motion-number="04">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">04</p>
    <h1 class="dv-name">テキストリビール</h1>
    <p class="dv-ai">Text reveal (Split text)</p>
    <p class="dv-desc">文字が1字ずつせり上がる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap">
    <section class="beat">
      <h2 class="dv-split-line" data-split aria-label="文字が1字ずつ">文字が1字ずつ</h2>
      <p class="dv-lead">本文のダミーです。見出しは画面の中央に来たときに、字ごとに下から出てきます。</p>
    </section>
    <section class="beat">
      <h2 class="dv-split-line" data-split aria-label="せり上がります">せり上がります</h2>
      <p class="dv-lead">本文のダミーです。一度出た字は、戻っても消えません。</p>
    </section>
    <section class="beat">
      <h2 class="dv-split-line" data-split aria-label="見出しが入ります">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。動きを減らす設定では、最初から全文を表示します。</p>
    </section>
  </div>
</main>

<footer class="dv-end">END — 04 テキストリビール</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 ---- */
for (const el of document.querySelectorAll('[data-split]')) {
  const chars = Array.from(el.textContent.trim());
  el.textContent = '';
  chars.forEach((ch, i) => {
    const span = document.createElement('span');
    span.className = 'ch';
    span.setAttribute('aria-hidden', 'true');
    span.style.setProperty('--i', i);
    span.textContent = ch;
    el.appendChild(span);
  });
}
dvReveal('[data-split]', { threshold: 0.5 });
</script>
</body>
</html>

05 motion-mask-reveal/demo.html

7.7 KB

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

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>05 マスクリビール — Clip-path 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() のみ ---- */
.beat { min-height: 70vh; display: grid; align-content: center; gap: var(--s3); }
/* 切り抜くのは中の箱。見張られる要素そのものを切り抜くと、面積がゼロと数えられて
   IntersectionObserver が「画面に入った」と言わなくなる。 */
.js [data-reveal^="mask"] > .dv-ph { transition: clip-path var(--dur) var(--ease); }
.js [data-reveal="mask"] > .dv-ph { clip-path: inset(0 100% 0 0); }
.js [data-reveal="mask-right"] > .dv-ph { clip-path: inset(0 0 0 100%); }
.js [data-reveal="mask-up"] > .dv-ph { clip-path: inset(100% 0 0 0); }
.js [data-reveal^="mask"].is-in > .dv-ph { clip-path: inset(0); }

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal^="mask"] > .dv-ph { clip-path: none; transition: none; }
}
</style>
</head>
<body data-motion-number="05">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">05</p>
    <h1 class="dv-name">マスクリビール</h1>
    <p class="dv-ai">Clip-path reveal</p>
    <p class="dv-desc">幕が開くように画像が出る</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="dv-wrap beat">
    <div data-reveal="mask"><div class="dv-ph"></div></div>
    <p class="dv-lead">左から開く画像のダミーです。</p>
  </section>
  <section class="dv-wrap beat">
    <div data-reveal="mask-right"><div class="dv-ph dv-ph--alt"></div></div>
    <p class="dv-lead">右から開く画像のダミーです。</p>
  </section>
  <section class="dv-wrap beat">
    <div data-reveal="mask-up"><div class="dv-ph dv-ph--ink"></div></div>
    <p class="dv-lead">下から開く画像のダミーです。</p>
  </section>
  <section class="dv-wrap beat">
    <div data-reveal="mask"><div class="dv-ph"></div></div>
    <p class="dv-lead">見出しが入ります。</p>
  </section>
</main>

<footer class="dv-end">END — 05 マスクリビール</footer>

<script>
/* dv-motion-helpers:start */
const dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.documentElement.classList.add('js');

/**
 * 画面に入った要素へ .is-in を付ける。
 * data-reveal="repeat" の要素は画面を出たら外す(何度でも再生)。
 */
function dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {
  const els = [...document.querySelectorAll(selector)];
  if (dvReduce || !('IntersectionObserver' in window)) {
    els.forEach((el) => el.classList.add('is-in'));
    return;
  }
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (e.isIntersecting) {
        e.target.classList.add('is-in');
        if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);
      } else if (e.target.dataset.reveal === 'repeat') {
        e.target.classList.remove('is-in');
      }
    }
  }, { threshold, rootMargin });
  els.forEach((el) => io.observe(el));
}

/**
 * スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。
 *   data-progress="through" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1
 *   data-progress="pin"     : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1
 *                             (中に position: sticky の箱を持つ、背の高い区画用)
 *   data-progress="page"    : ページ全体のスクロール量
 * onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。
 */
function dvProgress(onUpdate) {
  const els = [...document.querySelectorAll('[data-progress]')];
  const clamp = (n) => Math.min(1, Math.max(0, n));
  let queued = false;
  const measure = () => {
    queued = false;
    const vh = window.innerHeight;
    for (const el of els) {
      const r = el.getBoundingClientRect();
      let p;
      if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;
      else if (el.dataset.progress === 'page') {
        const max = document.documentElement.scrollHeight - vh;
        p = max > 0 ? window.scrollY / max : 0;
      } else p = (vh - r.top) / (vh + r.height);
      p = clamp(p);
      el.style.setProperty('--p', p.toFixed(4));
      if (onUpdate) onUpdate(el, p);
    }
  };
  const request = () => {
    if (!queued) { queued = true; requestAnimationFrame(measure); }
  };
  addEventListener('scroll', request, { passive: true });
  addEventListener('resize', request);
  measure();
}
/* dv-motion-helpers:end */

/* ---- この演出だけの JS ---- */
dvReveal();
</script>
</body>
</html>

06 motion-blur-in/demo.html

7.4 KB

/designs/motion-blur-in/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>06 ブラーイン — Blur-in 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() のみ ---- */
.beat { min-height: 70vh; display: grid; align-content: center; gap: var(--s3); }
.js [data-reveal="blur"] { opacity: 0; filter: blur(16px); transition: opacity var(--dur) var(--ease), filter var(--dur) var(--ease); }
.js [data-reveal="blur"].is-in { opacity: 1; filter: none; }

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal="blur"] { opacity: 1; filter: none; transition: none; }
}
</style>
</head>
<body data-motion-number="06">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">06</p>
    <h1 class="dv-name">ブラーイン</h1>
    <p class="dv-ai">Blur-in reveal</p>
    <p class="dv-desc">ぼかしからくっきり表示</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="dv-wrap beat">
    <div class="dv-card" data-reveal="blur">
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。ぼかしが消えてから、くっきり表示されます。</p>
    </div>
  </section>
  <section class="dv-wrap beat">
    <div class="dv-ph" data-reveal="blur"></div>
  </section>
  <section class="dv-wrap beat">
    <div class="dv-card" data-reveal="blur">
      <p class="dv-eyebrow">Section 2</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。戻っても、もう一度ぼかすことはありません。</p>
    </div>
  </section>
  <section class="dv-wrap beat">
    <div class="dv-ph dv-ph--ink" data-reveal="blur"></div>
  </section>
</main>

<footer class="dv-end">END — 06 ブラーイン</footer>

<script>
/* dv-motion-helpers:start */
const dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.documentElement.classList.add('js');

/**
 * 画面に入った要素へ .is-in を付ける。
 * data-reveal="repeat" の要素は画面を出たら外す(何度でも再生)。
 */
function dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {
  const els = [...document.querySelectorAll(selector)];
  if (dvReduce || !('IntersectionObserver' in window)) {
    els.forEach((el) => el.classList.add('is-in'));
    return;
  }
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (e.isIntersecting) {
        e.target.classList.add('is-in');
        if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);
      } else if (e.target.dataset.reveal === 'repeat') {
        e.target.classList.remove('is-in');
      }
    }
  }, { threshold, rootMargin });
  els.forEach((el) => io.observe(el));
}

/**
 * スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。
 *   data-progress="through" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1
 *   data-progress="pin"     : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1
 *                             (中に position: sticky の箱を持つ、背の高い区画用)
 *   data-progress="page"    : ページ全体のスクロール量
 * onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。
 */
function dvProgress(onUpdate) {
  const els = [...document.querySelectorAll('[data-progress]')];
  const clamp = (n) => Math.min(1, Math.max(0, n));
  let queued = false;
  const measure = () => {
    queued = false;
    const vh = window.innerHeight;
    for (const el of els) {
      const r = el.getBoundingClientRect();
      let p;
      if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;
      else if (el.dataset.progress === 'page') {
        const max = document.documentElement.scrollHeight - vh;
        p = max > 0 ? window.scrollY / max : 0;
      } else p = (vh - r.top) / (vh + r.height);
      p = clamp(p);
      el.style.setProperty('--p', p.toFixed(4));
      if (onUpdate) onUpdate(el, p);
    }
  };
  const request = () => {
    if (!queued) { queued = true; requestAnimationFrame(measure); }
  };
  addEventListener('scroll', request, { passive: true });
  addEventListener('resize', request);
  measure();
}
/* dv-motion-helpers:end */

/* ---- この演出だけの JS ---- */
dvReveal();
</script>
</body>
</html>

07 motion-typewriter/demo.html

8.2 KB

/designs/motion-typewriter/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>07 タイプライター — Typewriter effect</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() のみ ---- */
.beat { min-height: 80vh; display: grid; align-content: center; }
.type { font-size: var(--t-h2); font-weight: 700; line-height: 1.4; min-height: 1.4em; }
.type--body { font-size: var(--t-lead); font-weight: 400; color: var(--muted); line-height: 1.8; min-height: 1.8em; }
.type.is-typing::after {
  content: "";
  display: inline-block;
  width: .08em;
  height: 1em;
  margin-left: .06em;
  vertical-align: -.12em;
  background: var(--ink);
  animation: dv-blink 1s steps(1) infinite;
}
@keyframes dv-blink { 50% { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  .type.is-typing::after { display: none; }
}
</style>
</head>
<body data-motion-number="07">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">07</p>
    <h1 class="dv-name">タイプライター</h1>
    <p class="dv-ai">Typewriter effect</p>
    <p class="dv-desc">1文字ずつ打ち込まれる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="dv-wrap beat">
    <p class="type" data-type>見出しが入ります。</p>
  </section>
  <section class="dv-wrap beat">
    <p class="type type--body" data-type>本文のダミーです。画面に入ると、一文字ずつ打ち込まれて表示されます。</p>
  </section>
  <section class="dv-wrap beat">
    <p class="type" data-type>最後の一行も、同じように入ります。</p>
  </section>
</main>

<footer class="dv-end">END — 07 タイプライター</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 typed = [...document.querySelectorAll('[data-type]')];
if (!dvReduce && 'IntersectionObserver' in window) {
  const texts = new Map(typed.map((el) => [el, el.textContent]));
  typed.forEach((el) => { el.textContent = ''; });

  const typeOne = (el) => new Promise((done) => {
    const chars = [...texts.get(el)];
    let i = 0;
    el.classList.add('is-typing');
    const step = () => {
      el.textContent = chars.slice(0, ++i).join('');
      if (i < chars.length) setTimeout(step, 80);
      else { el.classList.remove('is-typing'); done(); }
    };
    setTimeout(step, 80);
  });

  // 行は画面に入った順に、前の行が打ち終わってから打つ。
  let queue = Promise.resolve();
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      io.unobserve(e.target);
      queue = queue.then(() => typeOne(e.target));
    }
  }, { threshold: 0.5 });
  typed.forEach((el) => io.observe(el));
}
</script>
</body>
</html>

08 motion-count-up/demo.html

9.0 KB

/designs/motion-count-up/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>08 カウントアップ — Count-up 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() のみ ---- */
.beat { min-height: 60vh; display: grid; align-content: center; gap: var(--s3); }
.count { font-size: var(--t-h1); font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
.unit { font-size: var(--t-h2); margin-left: var(--s2); }
.bar { height: 4px; border-radius: 2px; background: var(--accent); transform-origin: left center; }
.js .bar { transform: scaleX(0); transition: transform 1.6s var(--ease); }
.js .beat.is-in .bar { transform: scaleX(1); }

@media (prefers-reduced-motion: reduce) {
  .js .bar { transition: none; }
}
</style>
</head>
<body data-motion-number="08">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">08</p>
    <h1 class="dv-name">カウントアップ</h1>
    <p class="dv-ai">Count-up animation</p>
    <p class="dv-desc">数字が0から増えていく</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Metric 01</p>
    <p class="count"><span data-count="1280">1,280</span><span class="unit">件</span></p>
    <div class="bar"></div>
    <p class="dv-lead">項目の説明が入ります。</p>
  </section>
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Metric 02</p>
    <p class="count"><span data-count="98.6" data-decimals="1">98.6</span><span class="unit">%</span></p>
    <div class="bar"></div>
    <p class="dv-lead">項目の説明が入ります。</p>
  </section>
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Metric 03</p>
    <p class="count"><span data-count="42">42</span><span class="unit">点</span></p>
    <div class="bar"></div>
    <p class="dv-lead">項目の説明が入ります。</p>
  </section>
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Metric 04</p>
    <p class="count"><span data-count="3.2" data-decimals="1">3.2</span><span class="unit">秒</span></p>
    <div class="bar"></div>
    <p class="dv-lead">項目の説明が入ります。</p>
  </section>
</main>

<footer class="dv-end">END — 08 カウントアップ</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 counters = [...document.querySelectorAll('[data-count]')];
const beats = [...document.querySelectorAll('.beat')];
const fmt = (el, v) => {
  const d = Number(el.dataset.decimals || 0);
  el.textContent = v.toLocaleString('ja-JP', { minimumFractionDigits: d, maximumFractionDigits: d });
};
const countUp = (el) => {
  const end = Number(el.dataset.count);
  const t0 = performance.now();
  const ease = (t) => 1 - Math.pow(1 - t, 3);
  const frame = (now) => {
    const t = Math.min(1, (now - t0) / 1600);
    fmt(el, end * ease(t));
    if (t < 1) requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);
};

if (dvReduce || !('IntersectionObserver' in window)) {
  counters.forEach((el) => fmt(el, Number(el.dataset.count)));
  beats.forEach((b) => b.classList.add('is-in'));
} else {
  counters.forEach((el) => fmt(el, 0));
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      io.unobserve(e.target);
      e.target.classList.add('is-in');
      countUp(e.target.querySelector('[data-count]'));
    }
  }, { threshold: 0.5 });
  beats.forEach((b) => io.observe(b));
}
</script>
</body>
</html>