A still — with scripts on, it runs right here The demo itself — scroll inside the frame
A still — with scripts on, it runs right here The demo itself — scroll inside the frame
Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.
Everything in this panel, including the contents behind each URL, in one Markdown file — Large, so it goes as a file (copy is for 32 KB and under)
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
/designs/motion-sticky-cta/demo.html — One self-contained HTML file. CSS and plain JavaScript only; it fetches nothing.
<!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>