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

Deck

Atmosphere and Utilities 40–44 — Effects That Tune the Feel of Reading

Five effects from velocity skew to the sticky CTA. They are not the main act, but tune how the page feels and where the reader is led

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 Velocity skew Velocity skew
  2. 02 Motion Rotate on scroll Rotate on scroll
  3. 03 Motion Circular scroll progress Circular scroll progress
  4. 04 Motion Blend invert Blend invert
  5. 05 Motion Sticky CTA bar Sticky CTA bar

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-ambient-everything.md

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

Prompt

5.1 KB
# Velocity skew
Velocity skew — Scroll-velocity skew

Tilt groups of headings according to scroll speed. Measure the scroll amount per animation frame with requestAnimationFrame, multiply the velocity v by 0.02 and apply `skewY(clamp(-6deg, v * 0.02, 6deg))`. Normalise v to a 60 Hz frame so that the tilt does not change with screen refresh rate.
When scrolling stops, multiply v by 0.9 each frame so the tilt returns to 0 within a fraction of a second. Tilt five sections, each 80vh tall, containing headings and body text.
Write only `transform`, through a CSS variable `--skew`; do not use a library.

Do not drop:
- The tilt is capped at ±6 degrees, however fast the scroll
- When scrolling stops the tilt decays back to 0 and never stays tilted at rest
- With reduced motion, nothing tilts and no measuring loop starts

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

---

# Rotate on scroll
Rotate on scroll — Rotate on scroll

Place two meshing gears side by side and turn them with scroll. The left gear turns 360 degrees clockwise and the right gear 360 degrees counter-clockwise, at the same rate per unit of scroll.
Draw each gear as an SVG `path` with 14 teeth, and offset the teeth of the right gear by half a pitch so that the teeth stay meshed while they turn.
Make the section 300vh tall with a 100vh sticky box inside. Turn the gears only with `transform: rotate(calc(var(--p) * ±360deg))`, taking `--p` from the section's scroll progress.
Implement with CSS and plain JavaScript only; do not use a library.

Do not drop:
- The two gears turn in opposite directions by the same amount (±360 degrees)
- Both gears have 14 teeth and the offset is baked into the path, not applied by transform
- Turn only with rotate(); never move or resize the gears
- With reduced motion, the gears stay in their meshed starting position

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

---

# Circular scroll progress
Circular scroll progress — Circular scroll progress

Fix a 72px circular progress indicator at the bottom-right of the screen. The outer ring is an SVG circle with pathLength="1" and stroke-dasharray 1, and its stroke-dashoffset moves from 1 to 0 so the ring draws clockwise as the page scroll goes from 0% to 100%.
Show the current percentage in the centre in tabular numerals, for example "45%".
Once progress passes 90%, swap the centre to "↑"; pressing it scrolls smoothly back to the top (instantly when reduced motion is set). Draw the progress stroke in --accent and the track in --rule, and use only the named colour roles.
Implement with CSS and plain JavaScript only; do not use a library.

Do not drop:
- The ring tracks whole-page scroll and reaches 100% at the bottom
- Except under reduced motion, the up arrow appears only above 90% and is not clickable below it
- With reduced motion, the ring stays drawn complete

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

---

# Blend invert
Blend invert — mix-blend-mode invert

Place a fixed 50vmin disc at the centre of the frame, coloured `var(--ground)` and overlaid with `mix-blend-mode: difference`.
Beneath it, stack three zones of different ground colours (white, deep navy, blue) vertically. Inside a 300vh sticky section, move the zones upward by 200vh in step with --p.
Each time a boundary crosses the centre of the disc, only the part of the zone under the disc inverts. The disc grows from 0.8× to 1.2× with --p, and the copy sits outside the disc.
Implement with CSS and plain JavaScript only; do not use a library.

Do not drop:
- Inversion happens only where the disc overlaps; the ground outside it stays unchanged
- The disc is one colour, var(--ground), and never changes per zone
- Copy sits outside the disc so it stays readable even without blending
- With reduced motion, a zone boundary stays centred on the disc

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

---

# Sticky CTA bar
Sticky CTA bar — Sticky CTA bar

Pin a button bar to the bottom edge of the viewport, about 60px tall, holding one button and one line of copy.
Once the reader has passed the 72vh title block and scrolled one further screen, slide the bar from translateY(100%) up to translateY(0) over 0.7s with the ease curve.
When the top of the closing band (`.dv-end`) enters the bottom of the viewport, slide the bar back down and hide it.
Implement it with a CSS transition and a single passive scroll listener that reads the position; no library.

Do not drop:
- Stay hidden inside the title block; appear only after one further screen
- Hide once the closing band arrives, so the bar never covers the final content
- Keep the button on one line at 375px; truncate the copy rather than wrapping
- Under reduced motion, never hide it; keep the bar fixed in its final position

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

Spec

455 B
# Velocity skew
https://design-vault.pages.dev/ai/assets/motion-velocity-skew.json

---

# Rotate on scroll
https://design-vault.pages.dev/ai/assets/motion-rotate-on-scroll.json

---

# Circular scroll progress
https://design-vault.pages.dev/ai/assets/motion-circular-progress.json

---

# Blend invert
https://design-vault.pages.dev/ai/assets/motion-blend-invert.json

---

# Sticky CTA bar
https://design-vault.pages.dev/ai/assets/motion-sticky-cta.json

Files

01 motion-velocity-skew/demo.html

9.4 KB

/designs/motion-velocity-skew/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>40 ベロシティスキュー — Scroll-velocity skew</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(--s7); }
.vs-block { min-height: 80vh; display: grid; align-content: center; }
.vs-group { transform: skewY(var(--skew, 0deg)); transform-origin: 0 50%; will-change: transform; }
.vs-tag { font-family: var(--mono); font-size: var(--t-label); color: var(--accent); letter-spacing: .12em; }

@media (prefers-reduced-motion: reduce) {
  .vs-group { transform: none; }
}
</style>
</head>
<body data-motion-number="40">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">40</p>
    <h1 class="dv-name">ベロシティスキュー</h1>
    <p class="dv-ai">Scroll-velocity skew</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="vs-block">
      <div class="vs-group">
        <p class="vs-tag">Section 1</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <p class="dv-lead">本文のダミーです。速くスクロールすると、見出しの群が傾きます。</p>
      </div>
    </section>
    <section class="vs-block">
      <div class="vs-group">
        <p class="vs-tag">Section 2</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <p class="dv-lead">本文のダミーです。止めると、傾きは減衰して 0 に戻ります。</p>
      </div>
    </section>
    <section class="vs-block">
      <div class="vs-group">
        <p class="vs-tag">Section 3</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <div class="dv-ph"></div>
      </div>
    </section>
    <section class="vs-block">
      <div class="vs-group">
        <p class="vs-tag">Section 4</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <p class="dv-lead">本文のダミーです。動きを減らす設定では、傾かずに静止したままです。</p>
      </div>
    </section>
    <section class="vs-block">
      <div class="vs-group">
        <p class="vs-tag">Section 5</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <div class="dv-ph dv-ph--alt"></div>
      </div>
    </section>
  </div>
</main>

<footer class="dv-end">END — 40 ベロシティスキュー</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 ---- */
// スクロール速度(60Hz の 1 フレームあたりの px)を測り、減衰させながら
// 見出し群の --skew に写す。止まれば 0 に戻る。動きを減らす設定では動かさない。
if (!dvReduce) {
  const groups = [...document.querySelectorAll('.vs-group')];
  const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
  const decay = 0.9;
  let lastY = scrollY;
  let lastT = 0;
  let v = 0;
  let running = false;

  const frame = (now) => {
    const dt = lastT ? Math.min(now - lastT, 100) : 16.67;
    lastT = now;
    const dy = scrollY - lastY;
    lastY = scrollY;
    v = dy !== 0 ? (dy / dt) * 16.67 : v * Math.pow(decay, dt / 16.67);
    const skew = clamp(v * 0.02, -6, 6);
    for (const el of groups) el.style.setProperty('--skew', skew.toFixed(3) + 'deg');
    if (Math.abs(v) > 0.01) {
      requestAnimationFrame(frame);
    } else {
      v = 0;
      running = false;
      lastT = 0;
      for (const el of groups) el.style.setProperty('--skew', '0deg');
    }
  };
  // lastY はここでは更新しない。scroll イベントは同じフレームの rAF の直前に来るので、
  // ここで scrollY を写すと frame の差分が 0 になり、速さが一度も測れない。
  const start = () => {
    if (running) return;
    running = true;
    requestAnimationFrame(frame);
  };
  addEventListener('scroll', start, { passive: true });
}
</script>
</body>
</html>

02 motion-rotate-on-scroll/demo.html

9.6 KB

/designs/motion-rotate-on-scroll/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>41 スクロール回転 — Rotate on scroll</title>
<style>
/* dv-motion-base:start */
:root {
  --ground: #FFFFFF;
  --surface: #F3F5F8;
  --ink: #14181F;
  --muted: #5A6472;
  --accent: #1F5FA8;
  --rule: #D8DDE4;
  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));
  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));
  --shade: color-mix(in srgb, var(--ink) 14%, transparent);

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

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

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

  --radius: 12px;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --dur: .7s;
  --measure: 1080px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--t-body);
  line-height: 1.7;
  font-feature-settings: "palt";
  overflow-x: hidden;
}
h1, h2, h3, p, ul, ol, figure { margin: 0; padding: 0; }
ul, ol { list-style: none; }
.dv-wrap { width: min(100% - 2 * var(--s4), var(--measure)); margin-inline: auto; }
.dv-label {
  min-height: 72vh;
  display: grid;
  align-content: center;
  gap: var(--s3);
  padding-block: var(--s6);
  border-bottom: 1px solid var(--rule);
}
.dv-num { font-family: var(--mono); font-size: var(--t-lead); font-weight: 700; color: var(--accent); letter-spacing: .04em; }
.dv-name { font-size: var(--t-h1); font-weight: 700; line-height: 1.15; letter-spacing: .01em; }
.dv-ai { font-family: var(--mono); font-size: var(--t-lead); color: var(--muted); }
.dv-desc { font-size: var(--t-lead); color: var(--ink); }
.dv-hint { font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; margin-top: var(--s4); }
.dv-stage { padding-block: var(--s7); }
.dv-h2 { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; }
.dv-lead { font-size: var(--t-lead); color: var(--muted); }
.dv-eyebrow { font-family: var(--mono); font-size: var(--t-label); color: var(--accent); letter-spacing: .12em; text-transform: uppercase; }
.dv-card { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); padding: var(--s4); }
.dv-ph {
  border-radius: var(--radius);
  background: linear-gradient(135deg, var(--accent-soft), var(--accent-mid) 60%, var(--accent));
  aspect-ratio: 16 / 9;
}
.dv-ph--alt { background: linear-gradient(200deg, var(--surface), var(--accent-soft) 55%, var(--accent-mid)); }
.dv-ph--ink { background: linear-gradient(160deg, var(--ink), color-mix(in srgb, var(--ink) 55%, var(--accent))); }
.dv-end {
  min-height: 60vh;
  display: grid;
  place-items: center;
  border-top: 1px solid var(--rule);
  color: var(--muted);
  font-family: var(--mono);
  font-size: var(--t-label);
  letter-spacing: .12em;
}
/* dv-motion-base:end */
/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */
.stack { display: grid; gap: var(--s6); }
/* 歯車は 2 枚とも歯数 14。歯の半ピッチずれは path の座標に焼いてあり、回転は rotate() だけ */
.gears { height: 300vh; }
.gears-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; overflow: hidden; }
.gears svg { width: min(100% - 2 * var(--s4), 560px); height: auto; display: block; }
.gear { fill: var(--accent); }
.hub, .mark { fill: var(--ground); }
.spin-a { transform-origin: 140px 120px; transform: rotate(calc(var(--p) * 360deg)); }
.spin-b { transform-origin: 306px 120px; transform: rotate(calc(var(--p) * -360deg)); }

@media (prefers-reduced-motion: reduce) {
  .spin-a, .spin-b { transform: none; }
}
</style>
</head>
<body data-motion-number="41">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">41</p>
    <h1 class="dv-name">スクロール回転</h1>
    <p class="dv-ai">Rotate on scroll</p>
    <p class="dv-desc">歯車や円盤がスクロールで回る</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap stack">
    <section class="dv-card">
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。このあとの区画で、歯車がかみ合ったまま回ります。</p>
    </section>
  </div>

  <section class="gears" data-progress="pin">
    <div class="gears-stick">
      <svg viewBox="40 20 366 200" role="img" aria-label="かみ合う 2 枚の歯車">
        <g class="spin-a">
          <path class="gear" d="M215.44 110.81L229.71 112.74L229.71 127.26L215.44 129.19L211.96 144.46L223.97 152.38L217.67 165.47L203.99 161.01L194.22 173.26L201.61 185.61L190.25 194.66L179.86 184.71L165.74 191.51L167.04 205.84L152.88 209.07L147.83 195.60L132.17 195.60L127.12 209.07L112.96 205.84L114.26 191.51L100.14 184.71L89.75 194.66L78.39 185.61L85.78 173.26L76.01 161.01L62.33 165.47L56.03 152.38L68.04 144.46L64.56 129.19L50.29 127.26L50.29 112.74L64.56 110.81L68.04 95.54L56.03 87.62L62.33 74.53L76.01 78.99L85.78 66.74L78.39 54.39L89.75 45.34L100.14 55.29L114.26 48.49L112.96 34.16L127.12 30.93L132.17 44.40L147.83 44.40L152.88 30.93L167.04 34.16L165.74 48.49L179.86 55.29L190.25 45.34L201.61 54.39L194.22 66.74L203.99 78.99L217.67 74.53L223.97 87.62L211.96 95.54Z"/>
          <circle class="hub" cx="140" cy="120" r="16"/>
          <circle class="mark" cx="140" cy="70" r="7"/>
        </g>
        <g class="spin-b">
          <path class="gear" d="M381.60 127.83L395.07 132.88L391.84 147.04L377.51 145.74L370.71 159.86L380.66 170.25L371.61 181.61L359.26 174.22L347.01 183.99L351.47 197.67L338.38 203.97L330.46 191.96L315.19 195.44L313.26 209.71L298.74 209.71L296.81 195.44L281.54 191.96L273.62 203.97L260.53 197.67L264.99 183.99L252.74 174.22L240.39 181.61L231.34 170.25L241.29 159.86L234.49 145.74L220.16 147.04L216.93 132.88L230.40 127.83L230.40 112.17L216.93 107.12L220.16 92.96L234.49 94.26L241.29 80.14L231.34 69.75L240.39 58.39L252.74 65.78L264.99 56.01L260.53 42.33L273.62 36.03L281.54 48.04L296.81 44.56L298.74 30.29L313.26 30.29L315.19 44.56L330.46 48.04L338.38 36.03L351.47 42.33L347.01 56.01L359.26 65.78L371.61 58.39L380.66 69.75L370.71 80.14L377.51 94.26L391.84 92.96L395.07 107.12L381.60 112.17Z"/>
          <circle class="hub" cx="306" cy="120" r="16"/>
          <circle class="mark" cx="306" cy="70" r="7"/>
        </g>
      </svg>
    </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 — 41 スクロール回転</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-circular-progress/demo.html

10.0 KB

/designs/motion-circular-progress/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>42 円形プログレス — Circular scroll progress</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); }
.ring {
  position: fixed;
  right: var(--s3);
  bottom: var(--s3);
  z-index: 10;
  width: 72px;
  height: 72px;
  border-radius: 50%;
  background: var(--ground);
  box-shadow: 0 2px 12px var(--shade);
  display: grid;
  place-items: center;
}
.ring svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.ring-track { fill: none; stroke: var(--rule); stroke-width: 4; }
.ring-fg {
  fill: none;
  stroke: var(--accent);
  stroke-width: 4;
  stroke-linecap: round;
  stroke-dasharray: 1;
  stroke-dashoffset: calc(1 - var(--p, 0));
  transform: rotate(-90deg);
  transform-origin: 50% 50%;
}
.ring-num {
  font-family: var(--mono);
  font-size: var(--t-label);
  font-weight: 700;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
  transition: opacity var(--dur) var(--ease);
}
.ring-btn {
  position: absolute;
  inset: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--accent);
  font: inherit;
  font-size: var(--t-lead);
  font-weight: 700;
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
}
.ring.is-top .ring-num { opacity: 0; }
.ring.is-top .ring-btn { opacity: 1; pointer-events: auto; }
.ring-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .ring-fg { stroke-dashoffset: 0; }
}
</style>
</head>
<body data-motion-number="42">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">42</p>
    <h1 class="dv-name">円形プログレス</h1>
    <p class="dv-ai">Circular scroll progress</p>
    <p class="dv-desc">円が読了量を示し、頂上に戻れる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap stack">
    <section class="dv-card">
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。右下の輪が、どこまで読んだかを示します。</p>
    </section>
    <div class="dv-ph"></div>
    <section class="dv-card">
      <p class="dv-eyebrow">Section 2</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。読み進めるほど、輪の線が伸びていきます。</p>
    </section>
    <div class="dv-ph dv-ph--alt"></div>
    <section class="dv-card">
      <p class="dv-eyebrow">Section 3</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。ここまでの量が、輪の中の百分率で読めます。</p>
    </section>
    <div class="dv-ph dv-ph--ink"></div>
    <section class="dv-card">
      <p class="dv-eyebrow">Section 4</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。終わり近くで、輪の中の印が矢印に変わります。</p>
    </section>
    <div class="dv-ph"></div>
    <section class="dv-card">
      <p class="dv-eyebrow">Section 5</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。矢印を押すと、先頭まで戻ります。</p>
    </section>
    <div class="dv-ph dv-ph--alt"></div>
  </div>
</main>

<div class="ring" data-progress="page">
  <svg viewBox="0 0 72 72" aria-hidden="true">
    <circle class="ring-track" cx="36" cy="36" r="30"></circle>
    <circle class="ring-fg" cx="36" cy="36" r="30" pathLength="1"></circle>
  </svg>
  <span class="ring-num">0%</span>
  <button class="ring-btn" type="button" aria-label="先頭へ戻る" disabled>↑</button>
</div>

<footer class="dv-end">END — 42 円形プログレス</footer>

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

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

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

/* ---- この演出だけの JS ---- */
dvReveal();

const ring = document.querySelector('.ring');
const ringNum = ring.querySelector('.ring-num');
const ringBtn = ring.querySelector('.ring-btn');
dvProgress((el, p) => {
  // 動きを減らす設定では、輪は完成した状態で止めて見せる
  const shown = dvReduce ? 1 : p;
  const top = shown > 0.9;
  ringNum.textContent = Math.round(shown * 100) + '%';
  el.classList.toggle('is-top', top);
  ringBtn.disabled = !top;
});
ringBtn.addEventListener('click', () => {
  scrollTo({ top: 0, behavior: dvReduce ? 'auto' : 'smooth' });
});
</script>
</body>
</html>

04 motion-blend-invert/demo.html

8.9 KB

/designs/motion-blend-invert/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>43 ブレンド反転 — mix-blend-mode invert</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() のみ ---- */
.bi-pin { position: relative; height: 300vh; }
.bi-stick { position: sticky; top: 0; height: 100vh; overflow: hidden; isolation: isolate; }
.bi-track {
  position: absolute; top: 0; left: 0; right: 0;
  transform: translate3d(0, calc(var(--p) * -200vh), 0);
  will-change: transform;
}
.bi-zone { position: relative; height: 100vh; color: var(--ink); }
.bi-zone--light { background: var(--ground); }
.bi-zone--dark { background: var(--ink); color: var(--ground); }
.bi-zone--accent { background: var(--accent); color: var(--ground); }
.bi-zone .dv-eyebrow, .bi-zone .dv-lead { color: inherit; }
.bi-copy { position: absolute; left: var(--s4); right: var(--s4); top: 10vh; }
.bi-disc {
  position: absolute; left: 50%; top: 50%;
  width: 50vmin; aspect-ratio: 1;
  border-radius: 50%;
  background: var(--ground);
  mix-blend-mode: difference;
  transform: translate(-50%, -50%) scale(calc(0.8 + var(--p) * 0.4));
  pointer-events: none;
}
@media (min-width: 768px) {
  .bi-copy { right: auto; width: 34%; }
}

@media (prefers-reduced-motion: reduce) {
  .bi-track { transform: translate3d(0, -50vh, 0); }
  .bi-disc { transform: translate(-50%, -50%); }
}
</style>
</head>
<body data-motion-number="43">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">43</p>
    <h1 class="dv-name">ブレンド反転</h1>
    <p class="dv-ai">mix-blend-mode invert</p>
    <p class="dv-desc">重なった所だけ色が反転する</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <div class="dv-wrap stack">
    <section class="dv-card">
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。このあとの区画を、円がまたいで反転させます。</p>
    </section>
  </div>

  <section class="bi-pin" data-progress="pin">
    <div class="bi-stick">
      <div class="bi-track">
    <div class="bi-zone bi-zone--light">
      <div class="bi-copy">
        <p class="dv-eyebrow">Zone 1</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <p class="dv-lead">本文のダミーです。</p>
      </div>
    </div>
    <div class="bi-zone bi-zone--dark">
      <div class="bi-copy">
        <p class="dv-eyebrow">Zone 2</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <p class="dv-lead">本文のダミーです。</p>
      </div>
    </div>
    <div class="bi-zone bi-zone--accent">
      <div class="bi-copy">
        <p class="dv-eyebrow">Zone 3</p>
        <h2 class="dv-h2">見出しが入ります</h2>
        <p class="dv-lead">本文のダミーです。</p>
      </div>
    </div>
      </div>
      <span class="bi-disc" aria-hidden="true"></span>
    </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 — 43 ブレンド反転</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>

05 motion-sticky-cta/demo.html

9.3 KB

/designs/motion-sticky-cta/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>44 追従 CTA — Sticky CTA bar</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() のみ ---- */
.hd-section { min-height: 80vh; display: grid; align-content: center; }
.cta {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s3);
  padding: var(--s2) var(--s4);
  background: color-mix(in srgb, var(--ground) 94%, transparent);
  border-top: 1px solid var(--rule);
  transition: transform var(--dur) var(--ease), visibility var(--dur);
}
.cta-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--t-label); color: var(--muted); }
.cta-btn {
  flex-shrink: 0;
  white-space: nowrap;
  padding: var(--s2) var(--s4);
  border-radius: var(--radius);
  background: var(--accent);
  color: var(--ground);
  font-weight: 700;
  text-decoration: none;
}
.js .cta { transform: translateY(100%); visibility: hidden; }
.js .cta.is-shown { transform: none; visibility: visible; }

@media (prefers-reduced-motion: reduce) {
  .cta { transition: none; }
  .js .cta { transform: none; visibility: visible; }
}
</style>
</head>
<body data-motion-number="44">
<header class="dv-label">
  <div class="dv-wrap">
    <p class="dv-num">44</p>
    <h1 class="dv-name">追従 CTA</h1>
    <p class="dv-ai">Sticky CTA bar</p>
    <p class="dv-desc">途中から下端にボタン帯が現れる</p>
    <p class="dv-hint">SCROLL ↓</p>
  </div>
</header>

<main class="dv-stage">
  <aside class="cta" aria-label="案内">
    <p class="cta-text">資料のご請求はこちらから</p>
    <a class="cta-btn" href="#">無料で始める</a>
  </aside>
  <section class="hd-section">
    <div class="dv-wrap">
      <p class="dv-eyebrow">Section 1</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。名札を抜けて一画面ぶん進むと、下端に帯が現れます。</p>
    </div>
  </section>
  <section class="hd-section">
    <div class="dv-wrap">
      <p class="dv-eyebrow">Section 2</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。帯は読み進めているあいだ、下端に残ります。</p>
    </div>
  </section>
  <section class="hd-section">
    <div class="dv-wrap">
      <p class="dv-eyebrow">Section 3</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。終わりの帯が画面に入ると、帯は隠れます。</p>
    </div>
  </section>
  <section class="hd-section">
    <div class="dv-wrap">
      <p class="dv-eyebrow">Section 4</p>
      <h2 class="dv-h2">見出しが入ります</h2>
      <p class="dv-lead">本文のダミーです。ここまでが演出の舞台です。</p>
    </div>
  </section>
</main>

<footer class="dv-end">END — 44 追従 CTA</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 dvCta = document.querySelector('.cta');
const dvLabel = document.querySelector('.dv-label');
const dvEnd = document.querySelector('.dv-end');
let dvQueued = false;

/** 名札を抜けて一画面ぶん進んだら帯を出し、終わりの帯が入ったら隠す。 */
function dvCtaMeasure() {
  dvQueued = false;
  const vh = innerHeight;
  const past = dvLabel.getBoundingClientRect().bottom <= -vh;
  const atEnd = dvEnd.getBoundingClientRect().top < vh;
  dvCta.classList.toggle('is-shown', past && !atEnd);
}

if (!dvReduce) {
  addEventListener('scroll', () => {
    if (!dvQueued) { dvQueued = true; requestAnimationFrame(dvCtaMeasure); }
  }, { passive: true });
  addEventListener('resize', dvCtaMeasure);
  dvCtaMeasure();
}
</script>
</body>
</html>