Design Vault Search use case, structure, colour, notes /
Index Categories Tags 日本語

Deck

Landing-Page Structure 35–39 — Effects That Decide How Sections Connect

Five effects from the timeline to the shrinking hero. They decide how sections are laid out and handed over, and form the frame of a landing page

5 slides

Take it with you JSON However many slides, one action. They are joined in order, so the sequence survives the paste. A slot with no spec carries its title and the address of its JSON

Reading order

  1. 01 Motion Timeline progress fill Timeline progress fill
  2. 02 Motion Opposing-scroll columns Opposing-scroll columns
  3. 03 Motion Curtain reveal Curtain reveal
  4. 04 Motion Bars grow Bars grow
  5. 05 Motion Hero title shrink Hero title shrink

About this deck

Take it with you

Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.

Everything

scroll-motion-lp-everything.md

Everything in this panel, including the contents behind each URL, in one Markdown file

Prompt

5.4 KB
# Timeline progress fill
Timeline progress fill — Timeline progress fill

Build a vertical timeline of five steps, each occupying 40vh of the viewport height. A thin 2px track sits behind an accent-coloured fill of the same width, and the fill grows from top to bottom with `transform: scaleY(var(--p))`, where --p runs from 0 to 1 over the scroll progress of the whole section.
When the fill's leading edge passes the centre of step i (at (i + 0.5) / 5 of the section height), add `.is-on` to that step's round node, switching it from the ground colour to the accent over 0.7 seconds.
Above 720px, place the line at the centre and alternate the step text left and right; at 375px, pull the line to the left edge and stack the text in one column.
Implement with CSS and plain JavaScript only; do not use a library.

Do not drop:
- The fill grows in proportion to --p, and steps below its leading edge stay unlit
- A step lights only after the line has passed its centre, never before
- At 375px the track and nodes sit at the left edge with no horizontal scroll
- With reduced motion, the line stays at full length and every step stays lit

A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-timeline-fill/demo.html

---

# Opposing-scroll columns
Opposing-scroll columns — Opposing-scroll columns

Place two columns of cards side by side: the left column flows upward and the right column flows downward. Each column holds four cards.
From the scroll progress `--p` (0–1), move the left column with `translate3d(0, calc((var(--p) - 0.5) * -24vh), 0)` and the right column with the same expression with its sign reversed. The two columns travel in opposite directions across a 24vh range (±12vh).
Below 600px, stack the cards into one column and stop the motion.
Implement with CSS and plain JavaScript only; do not use a library.

Do not drop:
- The two columns always move in opposite directions
- Both columns travel the same distance (±12vh)
- With reduced motion, both columns stay still side by side

A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-opposite-columns/demo.html

---

# Curtain reveal
Curtain reveal — Curtain section reveal

Build an effect of three full-screen sections stacked in the same place. Each section is a 100vh box with position: sticky; top: 0, and the z-index is smaller for each section further down. Wrap them in an outer section 300vh tall. Compute the outer progress p (0 to 1). The first section lifts 100vh upward while p goes from 0 to 0.5, and the second lifts the same way while p goes from 0.5 to 1, so the next section appears beneath it. The lifting section casts a shadow and shrinks by about 4%. Implement it with plain CSS and JavaScript only, with no library.

Do not drop:
- Keep each section at 100vh inside a 300vh wrapper; changing the heights moves the lift boundaries
- Earlier sections sit in front (smaller z-index further down); reversing that brings the next section forward and the lift disappears
- The lift follows scroll progress p, never a timer
- Under prefers-reduced-motion, stack the sections in flow and stop the lift

A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-curtain-reveal/demo.html

---

# Bars grow
Bars grow — Chart bars grow on view

Build a bar chart of six monthly achievement rates. When the chart is 50% inside the bottom edge of the viewport, each bar grows upward from its baseline while its value counts up from 0% to the final value. Each bar starts 90ms after the previous one, and each growth takes 0.7 seconds with an easeOut curve, 1 − (1 − t)³. The final values are 38%, 52%, 47%, 68%, 81%, and 94%; the latest month uses the accent colour and the rest use a quiet muted colour. Also write the values in a table so they read without JavaScript. Implement it with CSS and JavaScript only, without any library.

Do not drop:
- The number only counts up from 0 to its final value and never goes back
- Keep the final values in the HTML and the table so they read without JavaScript
- With reduced motion, show full-height bars and final values immediately

A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-bar-grow/demo.html

---

# Hero title shrink
Hero title shrink — Hero title shrinks into header

Start the page with a full-screen large title that shrinks into the header position as the user scrolls. The outer section is 300vh tall and holds a 100vh box with position: sticky; top: 0. Compute the progress p (0 to 1) as p=0 when the section's top reaches the top of the screen and p=1 when its bottom reaches the bottom. Scale the title with `transform: scale(calc(1 - var(--p) * 0.82))`, shrinking it from 1 to 0.18 while it moves from the middle of the screen to the top edge, so that at p=1 it sits at the header's brand position (24px from the left, vertically centred in the 64px-tall header). The header is a separate fixed bar at the top, and its brand text appears only once the title has landed. Implement with CSS and plain JavaScript only; do not use a library.

Do not drop:
- The shrinking title sits inside the pinned section and finishes landing before the section ends
- Scale stays between 1 and 0.18, and the landed size matches the header text
- With reduced motion, skip the movement and show only the landed state in the header

A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-hero-shrink/demo.html

Spec

455 B
# Timeline progress fill
https://design-vault.pages.dev/ai/assets/motion-timeline-fill.json

---

# Opposing-scroll columns
https://design-vault.pages.dev/ai/assets/motion-opposite-columns.json

---

# Curtain reveal
https://design-vault.pages.dev/ai/assets/motion-curtain-reveal.json

---

# Bars grow
https://design-vault.pages.dev/ai/assets/motion-bar-grow.json

---

# Hero title shrink
https://design-vault.pages.dev/ai/assets/motion-hero-shrink.json

Files

01 motion-timeline-fill/demo.html

9.7 KB

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

Show contents
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>35 タイムライン — Timeline progress fill</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

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

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

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

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

/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.tl { position: relative; height: 200vh; }
.tl-track, .tl-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 11px;
  width: 2px;
}
.tl-track { background: var(--rule); }
.tl-fill {
  background: var(--accent);
  transform-origin: top;
  transform: scaleY(var(--p, 0));
}
.tl-step {
  position: relative;
  height: 40vh;
  display: grid;
  align-content: center;
  padding-inline-start: var(--s6);
}
.tl-body { display: grid; gap: var(--s2); }
.tl-node {
  position: absolute;
  left: 0;
  top: 50%;
  width: 24px;
  height: 24px;
  margin-top: -12px;
  border-radius: 50%;
  border: 2px solid var(--rule);
  background: var(--ground);
  transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.tl-node.is-on { background: var(--accent); border-color: var(--accent); }

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

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

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

<footer class="dv-end">END — 35 タイムライン</footer>

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

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

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

/* ---- この演出だけの JS ---- */
// 線の先端が段の中央((i + 0.5) / 5)を越えたら、その段のノードを灯す。
dvProgress((el, p) => {
  el.querySelectorAll('.tl-node').forEach((node, i) => {
    node.classList.toggle('is-on', dvReduce || p >= (i + 0.5) / 5);
  });
});
</script>
</body>
</html>

02 motion-opposite-columns/demo.html

8.2 KB

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

Show contents
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>36 逆走カラム — Opposing-scroll columns</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

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

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

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

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

/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.stack { display: grid; gap: var(--s6); }
.olc { padding-block: var(--s5); }
.olc-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s3); align-items: start; }
.olc-col { display: grid; gap: var(--s3); will-change: transform; }
.olc-col--up { transform: translate3d(0, calc((var(--p, 0.5) - 0.5) * -24vh), 0); }
.olc-col--down { transform: translate3d(0, calc((var(--p, 0.5) - 0.5) * 24vh), 0); }

@media (max-width: 599px) {
  .olc-grid { grid-template-columns: minmax(0, 1fr); }
  .olc-col { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .olc-col { transform: none; }
}
</style>
</head>
<body data-motion-number="36">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">36</p>
    <h1 class="dv-name">逆走カラム</h1>
    <p class="dv-ai">Opposing-scroll columns</p>
    <p class="dv-desc">左右の列が逆向きに流れる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap stack">
    <section class="dv-card">
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。このあとの 2 つの列が、逆向きに流れます。</p>
    </section>
  </div>

  <section class="olc" data-progress="through">
    <div class="dv-wrap olc-grid">
      <div class="olc-col olc-col--up">
        <div class="dv-card"><div class="dv-ph"></div></div>
        <div class="dv-card"><div class="dv-ph dv-ph--alt"></div></div>
        <div class="dv-card"><div class="dv-ph dv-ph--ink"></div></div>
        <div class="dv-card"><div class="dv-ph dv-ph--alt"></div></div>
      </div>
      <div class="olc-col olc-col--down">
        <div class="dv-card"><div class="dv-ph dv-ph--ink"></div></div>
        <div class="dv-card"><div class="dv-ph"></div></div>
        <div class="dv-card"><div class="dv-ph dv-ph--alt"></div></div>
        <div class="dv-card"><div class="dv-ph dv-ph--ink"></div></div>
      </div>
    </div>
  </section>

  <div class="dv-wrap stack">
    <section class="dv-card">
      <p class="dv-eyebrow">Section 2</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。区画の中ほどで左右の列が揃い、前後では上下にずれていきます。</p>
    </section>
  </div>
</main>

<footer class="dv-end">END — 36 逆走カラム</footer>

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

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

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

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

03 motion-curtain-reveal/demo.html

9.2 KB

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

Show contents
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>37 カーテンリビール — Curtain section reveal</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

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

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

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

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

/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.curtain-zone { position: relative; }
.curtain-sec {
  position: relative;
  height: 100vh;
  display: grid;
  align-content: center;
  padding-inline: var(--s3);
  background: var(--ground);
}
.curtain-sec:nth-child(1) { background: var(--surface); }
.curtain-sec:nth-child(2) { background: var(--accent-soft); }
.curtain-sec .dv-wrap { display: grid; gap: var(--s3); }
.curtain-sec .dv-ph { aspect-ratio: auto; height: min(28vh, 240px); }

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

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

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

<footer class="dv-end">END — 37 カーテンリビール</footer>

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

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

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

/* ---- この演出だけの JS ---- */
const curtainSecs = [...document.querySelectorAll('.curtain-sec')];
// 区画 1 は p の前半、区画 2 は後半で持ち上がる。区画 3 は最後まで残る。
dvProgress((el, p) => {
  curtainSecs.slice(0, -1).forEach((s, k) => {
    const q = Math.min(1, Math.max(0, p * 2 - k));
    s.style.setProperty('--q', q.toFixed(4));
  });
});
</script>
</body>
</html>

04 motion-bar-grow/demo.html

11.2 KB

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

Show contents
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>38 バーの成長 — Chart bars grow on view</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

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

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

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

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

/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.intro { min-height: 90vh; display: grid; align-content: center; gap: var(--s3); }
.outro { min-height: 60vh; display: grid; align-content: center; }
.plot {
  position: relative;
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  column-gap: var(--s2);
  padding-top: var(--s5);
  margin-bottom: var(--s6);
  border-bottom: 1px solid var(--muted);
}
.col { position: relative; height: 300px; --h: 1; }
.js .col { --h: 0; }
.bar {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: min(100%, 56px);
  height: calc(var(--v) * 1%);
  border-radius: 4px 4px 0 0;
  background: var(--muted);
  transform-origin: 50% 100%;
  transform: translateX(-50%) scaleY(var(--h));
}
.val {
  position: absolute;
  left: 50%;
  bottom: calc(var(--v) * 1% + 6px);
  white-space: nowrap;
  font-size: var(--t-lead);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
  transform: translate(-50%, calc((1 - var(--h)) * var(--v) * 3px));
}
.col.is-last .bar { background: var(--accent); }
.col.is-last .val { color: var(--accent); }
.axis {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: var(--s2);
  text-align: center;
  font-size: var(--t-label);
  color: var(--muted);
}
.dv-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.dv-table caption { text-align: left; padding-bottom: var(--s2); }
.dv-table th, .dv-table td { padding: var(--s2) var(--s3); border-bottom: 1px solid var(--rule); text-align: left; }
.dv-table th:last-child, .dv-table td:last-child { text-align: right; }

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

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

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

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

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

<footer class="dv-end">END — 38 バーの成長</footer>

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

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

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

/* ---- この演出だけの JS ---- */
const plot = document.querySelector('.plot');
const cols = [...document.querySelectorAll('.col')];
const STEP = 90;
const DUR = 700;
const fmt = (el, v) => { el.textContent = Math.round(v).toLocaleString('ja-JP'); };
const ease = (t) => 1 - Math.pow(1 - t, 3);

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

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

05 motion-hero-shrink/demo.html

9.1 KB

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

Show contents
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>39 ヒーロー縮小 — Hero title shrinks into header</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

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

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

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

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

/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
body { --hero: min(28vw, 200px); }
.stack { display: grid; gap: var(--s6); }
.js .hd-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 10; height: 64px; display: flex; align-items: center; justify-content: space-between; gap: var(--s3); padding-inline: var(--s4); background: color-mix(in srgb, var(--ground) 92%, transparent); border-bottom: 1px solid var(--rule); }
.hd-brand { font-size: calc(var(--hero) * 0.18); font-weight: 700; line-height: 1.15; color: var(--ink); }
.js .hd-brand { opacity: 0; }
.js .hd-bar.is-landed .hd-brand { opacity: 1; }
.hd-bar nav ul { display: flex; gap: var(--s4); font-size: var(--t-label); color: var(--muted); }
.shrink-zone { height: 300vh; }
.shrink-stick { position: sticky; top: 0; height: 100vh; overflow: hidden; }
.shrink-title {
  position: absolute;
  top: 50%;
  left: var(--s4);
  margin-top: -0.575em;
  font-size: var(--hero);
  font-weight: 700;
  line-height: 1.15;
  color: var(--ink);
  white-space: nowrap;
  transform-origin: 0 0;
  transform:
    translateY(calc(var(--p) * (32px - 50vh + 0.4715em)))
    scale(calc(1 - var(--p) * 0.82));
}
.shrink-lead {
  position: absolute;
  left: var(--s4);
  right: var(--s4);
  bottom: var(--s5);
  color: var(--muted);
  opacity: calc(1 - var(--p) * 2);
}

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

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

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

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

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

<footer class="dv-end">END — 39 ヒーロー縮小</footer>

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

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

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

/* ---- この演出だけの JS ---- */
const hdBar = document.querySelector('.hd-bar');
if (dvReduce) hdBar.classList.add('is-landed');
dvReveal();
dvProgress((el, p) => { if (!dvReduce) hdBar.classList.toggle('is-landed', p >= 0.999); });
</script>
</body>
</html>