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

一式

文字の動き 25–29 — 文字そのものを動かす演出

テキストスクランブルからオドメーターまで 5 種。見出しや数字の見せ方を変える動きで、LP の第一印象を作る

5 枚

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

読む順

  1. 01 動き テキストスクランブル テキストスクランブル
  2. 02 動き 字間の開閉 字間の開閉
  3. 03 動き マーカー線 マーカー線
  4. 04 動き ワードスワッパー ワードスワッパー
  5. 05 動き オドメーター オドメーター

この一式について

持ち帰る

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

全部入り

scroll-motion-type-everything.md

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

依頼文

7.5 KB
# テキストスクランブル
テキストスクランブル — Text scramble / decode effect

乱れた記号の列が、画面に入ると左から順に読める文へ定まる演出を作ってください。見出し(最大 72px、太字)と本文(20px、薄い色)を交互に 5 行並べ、どの行も最初は全部の文字をランダムな記号(# % & * + = ? ◆ ■ など 20 種)に置き換えておきます。行が画面の下から 30% の線を越えたら解読を始め、1.2 秒で終えます。最初の 20% の時間は全体が乱れたままで、残りの時間で左の文字から等間隔に 1 字ずつ元の文字に確定し、まだの文字は 50 ミリ秒ごとに別の記号へ切り替えます。乱れている文字はアクセント色を薄めた色、確定した文字は本来の文字色(0.2 秒で変化)にし、各文字の幅は元の文字の幅に固定して行が揺れないようにします。素の JavaScript(requestAnimationFrame と IntersectionObserver)と CSS だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 文字は左から順に確定し、一度確定した文字は二度と乱さない
- 各文字の幅を元の文字の幅に固定し、記号の切り替えで行の幅や折り返しを変えない
- 元の文は HTML と aria-label に残し、乱れた記号は読み上げさせない
- 動きを減らす設定では乱さず、最初から読める文を表示する

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

---

# 字間の開閉
字間の開閉 — Letter-spacing on scroll

大見出し 3 本の字間が、スクロール量に合わせて開いて閉じる演出を作る。各見出しの区画が画面を通り抜ける進み具合を 0〜1 の値 --p で受け取り、そのうち 0.25〜0.75(区画が画面に入りきってから出始めるまで)を 0〜1 に引き伸ばして使う。
`letter-spacing` は -0.04em から始まり、区画が画面の中央に来たとき 0.3em で最大になり、抜けるときに 0 へ戻る。値は時間ではなくスクロール量に直結させ、`transition` は付けない。
見出しは `clamp(28px, 7vw, 96px)` の太字で 8 字以内にし、字間が最大(0.3em)に広がっても 1 行に収まって横スクロールを出さない。見出しの箱は 2 行ぶんの高さ(文字サイズ × 2.6)で固定し、下の要素が動かないようにする。
動きを減らす設定では、字間を 0 にして止める。
CSS の `calc()`・`min()`・`max()` と、スクロール量を CSS 変数へ書く素の JavaScript だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 字間は -0.04em → 0.3em → 0 の順で、最大は区画が画面の中央に来たとき
- 字間が最大に広がっても(幅 340px 以上なら)見出しは 1 行に収まり、横スクロールを出さない
- 見出しの箱の高さは固定し、スクロール中にページの高さを変えない
- 動きを減らす設定では字間 0 で止める

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

---

# マーカー線
マーカー線 — Highlighter sweep

本文の中で強調したい語を <mark> で囲み、語が画面に入ったら、蛍光ペンのような線が左から右へ 0.7 秒で引かれるようにする。
線は linear-gradient の背景で作り、色はアクセント色を 32% 混ぜた半透明、高さは語の下側 62% にとどめる。
動かすのは background-size だけで、0% 100% から 100% 100% へ広げる(background-repeat: no-repeat、background-position: 0 0)。
語が画面の下端から 20% 上まで入り、その 6 割が見えた時点で引き始め、同じ文に語が複数あるときは先の語から 160ms ずつ遅らせる。
折り返す語句は box-decoration-break: clone にして行ごとに線が左端から引かれるようにし、文字の色と太さは変えない。
CSS の transition と IntersectionObserver だけで実装し、ライブラリは使わない。

外すと崩れる点:
- 線は一度引いたら、スクロールを戻しても消さない
- 折り返す語句には box-decoration-break: clone を付け、行の途中で線を切らない
- 線は文字の後ろに薄く引き、文字の色は変えずに読めるままにする
- 動きを減らす設定では、最初から線を引いた状態で見せる

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

---

# ワードスワッパー
ワードスワッパー — Rotating word swapper

一文の中の一語だけが、スクロールに応じて縦に入れ替わる演出を作ってください。外側の区画は高さ 300vh とし、その中に position: sticky; top: 0 の 100vh の箱を置いて、大きな一文(40〜72px、行の高さ 1.3)を中央に固定します。入れ替わる語は高さ 1.3em・overflow: hidden の枠に入れ、枠の中に 4 語を縦に並べます。区画の上端が画面の上端に来たとき p=0、区画の下端が画面の下端に来たとき p=1 として進み具合 p(0〜1)を求め、k = floor(p × 4)(最大 3)の番号ぶん、語の列を translateY(k × -1.3em) で上へ送ります。送りは 0.7 秒の transform の transition で行い、枠の外の文は動かしません。CSS と素の JavaScript だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 枠の高さと語 1 つの高さを、行の高さ(1.3em)に揃える(ずれると前後の語がのぞく)
- 枠の幅は 4 語のうち最も長い語に合わせて固定する(語ごとに幅を変えると後ろの文が左右に動く)
- 語の番号は進み具合 p から決め、時間では切り替えない
- 動きを減らす設定では固定を解除し、4 語目が入った完成した文を止めて見せる

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

---

# オドメーター
オドメーター — Odometer digit roll

4 桁の数値を、桁ごとに数字が縦に回って止まるオドメーターとして作ってください。各桁は高さ 1 段ぶんの窓(overflow: hidden)で、中に 0〜9 を縦に並べた帯を置き、帯を transform の translateY で「止まる数字 × −10%」まで送ります。送る時間は 1 桁あたり 1.2 秒、緩急は cubic-bezier(.2, .7, .2, 1) で、左の桁から 120ms ずつ遅らせて順に止めます。開始は区画が画面に 50% 入ったときの一度だけで、戻っても巻き戻しません。数字には font-variant-numeric: tabular-nums を使い、最終値は aria-label にも書いてください。CSS の transition と IntersectionObserver だけで実装し、ライブラリは使わないでください。

外すと崩れる点:
- 各桁は 0〜9 の帯を transform で送る。文字を書き換えて数えない
- 左の桁から 120ms ずつ遅らせて止め、全桁を同時に止めない
- 画面に入ったら一度だけ回し、止まった数値は戻っても動かさない
- JS が動かない場合と動きを減らす設定では、最終値を止めて見せる

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

仕様

480 B
# テキストスクランブル
https://design-vault.pages.dev/ai/assets/motion-text-scramble.json

---

# 字間の開閉
https://design-vault.pages.dev/ai/assets/motion-tracking-expand.json

---

# マーカー線
https://design-vault.pages.dev/ai/assets/motion-highlighter-sweep.json

---

# ワードスワッパー
https://design-vault.pages.dev/ai/assets/motion-word-swapper.json

---

# オドメーター
https://design-vault.pages.dev/ai/assets/motion-odometer-roll.json

ファイル

01 motion-text-scramble/demo.html

12.3 KB

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

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>25 テキストスクランブル — Text scramble / decode 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: 46vh; display: grid; align-content: center; gap: var(--s3); }
.sc { overflow-wrap: anywhere; }
.sc--head { font-size: var(--t-h1); font-weight: 700; line-height: 1.25; }
.sc--body { font-size: var(--t-lead); line-height: 1.8; color: var(--muted); }
.sc .ch { display: inline-block; text-align: center; white-space: pre; color: var(--accent-mid); transition: color .2s var(--ease); }
.sc .ch.is-set { color: inherit; }
.meter { display: flex; align-items: center; gap: var(--s3); font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .08em; }
.meter-bar { flex: 0 1 240px; height: 2px; background: var(--rule); }
.meter-bar i { display: block; height: 100%; background: var(--accent); transform-origin: left center; }
.js .meter-bar i { transform: scaleX(var(--k, 0)); }
.meter-count { font-variant-numeric: tabular-nums; }

@media (prefers-reduced-motion: reduce) {
  .sc .ch { transition: none; }
  .js .meter-bar i { transform: none; }
}
</style>
</head>
<body data-motion-number="25">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">25</p>
    <h1 class="dv-name">テキストスクランブル</h1>
    <p class="dv-ai">Text scramble / decode effect</p>
    <p class="dv-desc">乱れた文字が読める文に定まる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Line 1</p>
    <h2 class="sc sc--head" data-scramble>見出しが入ります</h2>
    <div class="meter" aria-hidden="true"><span class="meter-bar"><i></i></span><span class="meter-count"></span></div>
  </section>
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Line 2</p>
    <p class="sc sc--body" data-scramble>本文のダミーです。画面に入ると、乱れた記号が左から順に読める文へ定まります。</p>
    <div class="meter" aria-hidden="true"><span class="meter-bar"><i></i></span><span class="meter-count"></span></div>
  </section>
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Line 3</p>
    <h2 class="sc sc--head" data-scramble>記号から文へ、左から順に</h2>
    <div class="meter" aria-hidden="true"><span class="meter-bar"><i></i></span><span class="meter-count"></span></div>
  </section>
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Line 4</p>
    <p class="sc sc--body" data-scramble>二つ目の本文のダミーです。一度定まった文字は、そのあと動きません。</p>
    <div class="meter" aria-hidden="true"><span class="meter-bar"><i></i></span><span class="meter-count"></span></div>
  </section>
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Line 5</p>
    <h2 class="sc sc--head" data-scramble>最後の一行も同じです</h2>
    <div class="meter" aria-hidden="true"><span class="meter-bar"><i></i></span><span class="meter-count"></span></div>
  </section>
</main>

<footer class="dv-end">END — 25 テキストスクランブル</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 GLYPHS = ['#', '%', '&', '*', '+', '=', '?', '/', '<', '>', '!', '$', '@', '~', '^', '◆', '■', '▲', '●', '×'];
const DURATION = 1200; // 1 行が定まりきるまで
const SWAP = 50;       // 記号を切り替える間隔
const HOLD = 0.2;      // 最初の 1 字が定まるまで、全体が乱れたままの割合
const SLIM = 15;       // 先頭の 15 個は幅の狭い記号。句読点のような狭い字にはこちらだけを使う
const glyph = (slim) => GLYPHS[Math.floor(Math.random() * (slim ? SLIM : GLYPHS.length))];

const lines = [...document.querySelectorAll('[data-scramble]')].map((el) => {
  const meter = el.parentElement.querySelector('.meter');
  const text = el.textContent.trim();
  const total = Array.from(text).filter((ch) => !/\s/.test(ch)).length;
  return { el, text, total, bar: meter.querySelector('i'), count: meter.querySelector('.meter-count'), chars: [] };
});
const report = (line, set) => {
  line.bar.style.setProperty('--k', (set / line.total).toFixed(3));
  line.count.textContent = `${set} / ${line.total}`;
};
lines.forEach((line) => line.el.setAttribute('aria-label', line.text));

if (dvReduce || !('IntersectionObserver' in window)) {
  lines.forEach((line) => report(line, line.total));
} else {
  // 1 字ずつ <span> に割る。空白は割らない。
  for (const line of lines) {
    line.el.textContent = '';
    for (const ch of Array.from(line.text)) {
      if (/\s/.test(ch)) { line.el.appendChild(document.createTextNode(ch)); continue; }
      const span = document.createElement('span');
      span.className = 'ch';
      span.setAttribute('aria-hidden', 'true');
      span.textContent = ch;
      line.el.appendChild(span);
      line.chars.push({ span, ch });
    }
  }
  // 記号は元の字と幅が違う。元の字の幅を em で固定して、切り替えのたびに行が揺れないようにする。
  for (const line of lines) {
    const size = parseFloat(getComputedStyle(line.el).fontSize);
    line.widths = line.chars.map(({ span }) => span.getBoundingClientRect().width / size);
  }
  for (const line of lines) {
    line.chars.forEach(({ span }, i) => {
      span.style.width = `${line.widths[i].toFixed(3)}em`;
      span.textContent = glyph(line.chars[i].slim = line.widths[i] < 0.8);
    });
    report(line, 0);
  }

  const decode = (line) => {
    const n = line.chars.length;
    const start = performance.now();
    let set = 0;
    let lastTick = -1;
    const frame = (now) => {
      const t = now - start;
      const due = t >= DURATION ? n : Math.max(0, Math.floor(((t / DURATION - HOLD) / (1 - HOLD)) * n));
      for (; set < due; set++) {
        line.chars[set].span.textContent = line.chars[set].ch;
        line.chars[set].span.classList.add('is-set');
      }
      const tick = Math.floor(t / SWAP);
      if (tick !== lastTick) {
        lastTick = tick;
        for (let i = set; i < n; i++) line.chars[i].span.textContent = glyph(line.chars[i].slim);
      }
      report(line, set);
      if (set < n) requestAnimationFrame(frame);
    };
    requestAnimationFrame(frame);
  };

  // 画面の下から 30% の線を越えた行から定める。上側は広く取り、飛び越した行も拾う。
  const byEl = new Map(lines.map((line) => [line.el, line]));
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      io.unobserve(e.target);
      decode(byEl.get(e.target));
    }
  }, { threshold: 0.9, rootMargin: '100000px 0px -30% 0px' });
  lines.forEach((line) => io.observe(line.el));
}
</script>
</body>
</html>

02 motion-tracking-expand/demo.html

9.2 KB

/designs/motion-tracking-expand/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>26 字間の開閉 — Letter-spacing on scroll</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

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

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

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

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

/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.trk-list { display: grid; gap: 36vh; padding-block: 24vh; }
.trk {
  --fs: clamp(28px, 7vw, 96px);
  /* 画面に入りきってから出始めるまで(--p が 0.25〜0.75)を 0〜1 に引き伸ばす */
  --q: clamp(0, (var(--p, 1) - 0.25) / 0.5, 1);
  display: grid;
  gap: var(--s3);
  padding-block: var(--s4);
  border-block: 1px solid var(--rule);
}
/* 箱の高さは 2 行ぶんで固定する。字は 7vw 以下なので、字間が最大でも 8 字は 1 行に収まり、下の要素が動かず --p が揺れない */
.trk-box { height: calc(var(--fs) * 2.6); display: grid; align-content: center; }
.trk-head {
  font-size: var(--fs);
  font-weight: 700;
  line-height: 1.3;
  overflow-wrap: anywhere;
  /* -0.04em(--q = 0)→ 0.3em(0.5)→ 0(1) */
  letter-spacing: calc((-0.04 + 0.68 * min(var(--q), 0.5) - 0.6 * max(var(--q) - 0.5, 0)) * 1em);
}
.trk-read { font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .04em; font-variant-numeric: tabular-nums; }

@media (prefers-reduced-motion: reduce) {
  .trk-head { letter-spacing: 0; }
}
</style>
</head>
<body data-motion-number="26">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">26</p>
    <h1 class="dv-name">字間の開閉</h1>
    <p class="dv-ai">Letter-spacing on scroll</p>
    <p class="dv-desc">字間がスクロールで広がり、また詰まる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap">
    <section class="dv-card">
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。このあとの大見出しは、画面の中央へ近づくほど字間が広がります。</p>
    </section>
    <div class="trk-list">
      <section class="trk" data-progress="through">
        <p class="dv-eyebrow">Heading 1</p>
        <div class="trk-box"><h2 class="trk-head">見出しが入ります</h2></div>
        <p class="trk-read">letter-spacing: <span data-read>0.00</span>em</p>
      </section>
      <section class="trk" data-progress="through">
        <p class="dv-eyebrow">Heading 2</p>
        <div class="trk-box"><h2 class="trk-head">字間がひらきます</h2></div>
        <p class="trk-read">letter-spacing: <span data-read>0.00</span>em</p>
      </section>
      <section class="trk" data-progress="through">
        <p class="dv-eyebrow">Heading 3</p>
        <div class="trk-box"><h2 class="trk-head">もとに戻ります</h2></div>
        <p class="trk-read">letter-spacing: <span data-read>0.00</span>em</p>
      </section>
    </div>
    <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 — 26 字間の開閉</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 ---- */
// 字間の値は CSS が --p から作る。ここでは、その結果を読んで数値の表示へ写すだけ。
if (!dvReduce) {
  dvProgress((el) => {
    const cs = getComputedStyle(el.querySelector('.trk-head'));
    const em = parseFloat(cs.letterSpacing) / parseFloat(cs.fontSize);
    el.querySelector('[data-read]').textContent = (Number.isFinite(em) ? em : 0).toFixed(2);
  });
}
</script>
</body>
</html>

03 motion-highlighter-sweep/demo.html

8.9 KB

/designs/motion-highlighter-sweep/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>27 マーカー線 — Highlighter sweep</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() のみ ---- */
.hl-stack { display: grid; gap: var(--s7); }
.hl-block { display: grid; gap: var(--s3); align-content: center; min-height: 56vh; }
.hl-text { font-size: clamp(22px, 3.4vw, 40px); font-weight: 700; line-height: 1.9; }
/* 線は語の下 62% にだけ引く。折り返した行のそれぞれが自分の線を持つよう clone にする */
.hl {
  padding-inline: .12em;
  color: inherit;
  background-color: transparent;
  background-image: linear-gradient(to top, color-mix(in srgb, var(--accent) 32%, transparent) 62%, transparent 62%);
  background-repeat: no-repeat;
  background-position: 0 0;
  background-size: 100% 100%;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  transition: background-size var(--dur) var(--ease) calc(var(--i, 0) * 160ms);
}
.js .hl { background-size: 0% 100%; }
.js .hl.is-in { background-size: 100% 100%; }

@media (prefers-reduced-motion: reduce) {
  .js .hl { background-size: 100% 100%; transition: none; }
}
</style>
</head>
<body data-motion-number="27">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">27</p>
    <h1 class="dv-name">マーカー線</h1>
    <p class="dv-ai">Highlighter sweep</p>
    <p class="dv-desc">文中の語にマーカーが引かれる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap hl-stack">
    <section class="hl-block">
      <p class="dv-eyebrow">Section 1</p>
      <p class="hl-text">ここには説明の文章が入ります。読んでほしい<mark class="hl">大事な語</mark>に、画面へ入った順でマーカーが引かれます。</p>
    </section>
    <section class="hl-block">
      <p class="dv-eyebrow">Section 2</p>
      <p class="hl-text">線は左から右へ伸びます。<mark class="hl">行をまたいで折り返す長い語句でも、線は途中で切れずに行ごとに引かれます</mark>。</p>
    </section>
    <section class="hl-block">
      <p class="dv-eyebrow">Section 3</p>
      <p class="hl-text">一つの文に<mark class="hl" style="--i: 0">印を付けたい語</mark>が<mark class="hl" style="--i: 1">いくつか</mark>あるときは、<mark class="hl" style="--i: 2">先に出てくる語</mark>から少しずつ遅れて引かれます。</p>
    </section>
    <section class="hl-block">
      <p class="dv-eyebrow">Section 4</p>
      <p class="hl-text">一度引いた線は、<mark class="hl">スクロールを戻しても消えません</mark>。読み返すときも、印はそのまま残ります。</p>
    </section>
    <section class="hl-block">
      <p class="dv-eyebrow">Section 5</p>
      <p class="hl-text">線の色は<mark class="hl" style="--i: 0">アクセントの一色だけ</mark>を薄く混ぜて使い、<mark class="hl" style="--i: 1">文字の色は変えません</mark>。</p>
    </section>
  </div>
</main>

<footer class="dv-end">END — 27 マーカー線</footer>

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

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

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

/* ---- この演出だけの JS ---- */
// 語そのものを見張る。画面の下端から 2 割ほど上まで入ったところで線を引き始める。
dvReveal('.hl', { threshold: 0.6, rootMargin: '0px 0px -20% 0px' });
</script>
</body>
</html>

04 motion-word-swapper/demo.html

9.4 KB

/designs/motion-word-swapper/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>28 ワードスワッパー — Rotating word swapper</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() のみ ---- */
.swap-zone { height: 300vh; }
.swap-box {
  position: sticky;
  top: 0;
  height: 100vh;
  display: grid;
  align-content: center;
  gap: var(--s4);
}
.swap-line {
  --lh: 1.3;
  font-size: var(--t-h1);
  font-weight: 700;
  line-height: var(--lh);
  letter-spacing: .01em;
}
.swap-part { display: inline-block; white-space: nowrap; }
/* 語の枠。高さを 1 行ぶんに揃え、はみ出した前後の語を切る */
.swap-win {
  display: inline-grid;
  height: calc(var(--lh) * 1em);
  overflow: hidden;
  vertical-align: bottom;
  margin-inline: .08em;
  padding-inline: .14em;
  border-radius: var(--radius);
  background: var(--accent-soft);
  color: var(--accent);
}
.swap-roll {
  display: grid;
  transform: translateY(calc(var(--k, 0) * -1 * var(--lh) * 1em));
  transition: transform var(--dur) var(--ease);
}
.swap-word { height: calc(var(--lh) * 1em); white-space: nowrap; }
.swap-foot { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--s3); }
.swap-count { font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; font-variant-numeric: tabular-nums; }
.swap-ticks { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s2); }
.swap-tick {
  height: 4px;
  border-radius: 2px;
  background: var(--rule);
  transition: background-color var(--dur) var(--ease);
}
.swap-tick.is-on { background: var(--accent); }
html:not(.js) .swap-foot { display: none; }

@media (prefers-reduced-motion: reduce) {
  .swap-zone { height: auto; }
  .swap-box { position: static; height: auto; padding-block: var(--s7); }
  .swap-roll, .swap-tick { transition: none; }
}
</style>
</head>
<body data-motion-number="28">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">28</p>
    <h1 class="dv-name">ワードスワッパー</h1>
    <p class="dv-ai">Rotating word swapper</p>
    <p class="dv-desc">一語が縦に入れ替わっていく</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap">
    <section class="swap-zone" data-progress="pin">
      <div class="swap-box">
        <p class="dv-eyebrow">Word swapper</p>
        <h2 class="swap-line">
          <span class="swap-part">毎日の</span><span class="swap-part"><span class="swap-win"><span class="swap-roll"><span class="swap-word">資料</span><span class="swap-word">提案</span><span class="swap-word">報告</span><span class="swap-word">企画</span></span></span>づくりを、</span><span class="swap-part">もっと速く。</span>
        </h2>
        <p class="dv-lead">本文のダミーです。文は動かず、枠の中の一語だけが入れ替わります。</p>
        <div class="swap-foot">
          <p class="swap-count" aria-hidden="true">01 / 04</p>
          <div class="swap-ticks" aria-hidden="true">
            <span class="swap-tick"></span>
            <span class="swap-tick"></span>
            <span class="swap-tick"></span>
            <span class="swap-tick"></span>
          </div>
        </div>
      </div>
    </section>
  </div>
</main>

<footer class="dv-end">END — 28 ワードスワッパー</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((el, p) => {
  // 進み具合を 4 等分し、その番号ぶんだけ語の列を 1 行ずつ上へ送る
  const k = dvReduce ? 3 : Math.min(3, Math.floor(p * 4));
  el.style.setProperty('--k', k);
  el.querySelectorAll('.swap-word').forEach((w, i) => w.setAttribute('aria-hidden', i === k ? 'false' : 'true'));
  el.querySelectorAll('.swap-tick').forEach((t, i) => t.classList.toggle('is-on', i <= k));
  el.querySelector('.swap-count').textContent = '0' + (k + 1) + ' / 04';
});
</script>
</body>
</html>

05 motion-odometer-roll/demo.html

11.4 KB

/designs/motion-odometer-roll/demo.html

中身を見る
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>29 オドメーター — Odometer digit roll</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: 72vh; display: grid; align-content: center; justify-items: start; gap: var(--s3); }
.odo-row { display: flex; align-items: flex-end; gap: var(--s3); }
.odo {
  display: flex;
  gap: var(--s2);
  font-size: clamp(56px, 15vw, 152px);
  font-weight: 700;
  line-height: 1.15;
  font-variant-numeric: tabular-nums;
}
/* 桁の窓。高さは 1 段ぶん(em で決める)で、はみ出した段を隠す */
.digit {
  position: relative;
  display: block;
  height: 1.15em;
  padding-inline: .14em;
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
}
.digit::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(var(--shade), transparent 24%, transparent 76%, var(--shade));
  pointer-events: none;
}
/* 0〜9 の縦並び。10 段なので、1 段は自身の高さの 10% */
.reel { display: block; transform: translateY(calc(var(--d) * -10%)); }
.reel > span { display: block; height: 1.15em; text-align: center; }
.unit { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; padding-bottom: .5em; }
/* 最大 9 段を送るので、共通の --dur(0.7 秒)では数字が読めない速さになる。1.2 秒にする */
.js .reel { transform: none; transition: transform 1.2s var(--ease) calc(var(--i) * 120ms); will-change: transform; }
.js .beat.is-in .reel { transform: translateY(calc(var(--d) * -10%)); }

@media (prefers-reduced-motion: reduce) {
  .js .reel { transform: translateY(calc(var(--d) * -10%)); transition: none; }
}
</style>
</head>
<body data-motion-number="29">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">29</p>
    <h1 class="dv-name">オドメーター</h1>
    <p class="dv-ai">Odometer digit roll</p>
    <p class="dv-desc">桁ごとに数字が回って止まる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Counter 01</p>
    <p class="odo-row">
      <span class="odo" role="img" aria-label="4817">
        <span class="digit"><span class="reel" style="--d:4; --i:0"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
        <span class="digit"><span class="reel" style="--d:8; --i:1"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
        <span class="digit"><span class="reel" style="--d:1; --i:2"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
        <span class="digit"><span class="reel" style="--d:7; --i:3"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
      </span>
      <span class="unit">件</span>
    </p>
    <p class="dv-lead">項目の説明が入ります。</p>
  </section>
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Counter 02</p>
    <p class="odo-row">
      <span class="odo" role="img" aria-label="9263">
        <span class="digit"><span class="reel" style="--d:9; --i:0"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
        <span class="digit"><span class="reel" style="--d:2; --i:1"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
        <span class="digit"><span class="reel" style="--d:6; --i:2"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
        <span class="digit"><span class="reel" style="--d:3; --i:3"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
      </span>
      <span class="unit">回</span>
    </p>
    <p class="dv-lead">項目の説明が入ります。</p>
  </section>
  <section class="dv-wrap beat">
    <p class="dv-eyebrow">Counter 03</p>
    <p class="odo-row">
      <span class="odo" role="img" aria-label="7538">
        <span class="digit"><span class="reel" style="--d:7; --i:0"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
        <span class="digit"><span class="reel" style="--d:5; --i:1"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
        <span class="digit"><span class="reel" style="--d:3; --i:2"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
        <span class="digit"><span class="reel" style="--d:8; --i:3"><span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span></span></span>
      </span>
      <span class="unit">点</span>
    </p>
    <p class="dv-lead">項目の説明が入ります。</p>
  </section>
</main>

<footer class="dv-end">END — 29 オドメーター</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 ---- */
// 区画が半分入ったら一度だけ .is-in を付ける。桁ごとの時間差は CSS の --i が作る。
dvReveal('.beat', { threshold: 0.5, rootMargin: '0px' });
</script>
</body>
</html>