{
  "id": "motion:motion-sticky-cta",
  "slug": "motion-sticky-cta",
  "kind": "motion",
  "title": {
    "ja": "追従 CTA",
    "en": "Sticky CTA bar"
  },
  "aiName": "Sticky CTA bar",
  "summary": {
    "ja": "途中から下端にボタン帯が現れる",
    "en": "A button bar rises from the bottom edge partway down the page"
  },
  "number": 44,
  "group": "ambient",
  "techniques": [
    "css-transition",
    "scroll-progress"
  ],
  "rules": [
    {
      "ja": "名札の中では出さない。名札を抜けて一画面ぶん進んでから初めて現れる",
      "en": "Stay hidden inside the title block; appear only after one further screen"
    },
    {
      "ja": "終わりの帯に着いたら隠す。帯がボタンと重なって最後の見出しを隠さないようにする",
      "en": "Hide once the closing band arrives, so the bar never covers the final content"
    },
    {
      "ja": "ボタンは一行に収める。375px でも折り返さず、案内文は省略して収める",
      "en": "Keep the button on one line at 375px; truncate the copy rather than wrapping"
    },
    {
      "ja": "動きを減らす設定では JS で隠さず、帯は完成した位置に止めて常に見せる",
      "en": "Under reduced motion, never hide it; keep the bar fixed in its final position"
    }
  ],
  "reproduction": {
    "prompt": {
      "ja": "追従 CTA — Sticky CTA bar\n\n画面下端に固定したボタン帯（高さ約 60px、ボタン 1 つと一行の案内文）を置く。\n名札（高さ 72vh）を抜けて、さらに画面 1 つぶん進んだところで、帯を translateY(100%) の位置から translateY(0) へ 0.7 秒の ease で持ち上げる。\n終わりの帯（`.dv-end`）の上端が画面の下端に入ったら、帯を下へ戻して隠す。\n実装は CSS の transition とスクロールの位置を読む 1 本の passive リスナーだけで行い、ライブラリは使わない。\n\n外すと崩れる点:\n- 名札の中では出さない。名札を抜けて一画面ぶん進んでから初めて現れる\n- 終わりの帯に着いたら隠す。帯がボタンと重なって最後の見出しを隠さないようにする\n- ボタンは一行に収める。375px でも折り返さず、案内文は省略して収める\n- 動きを減らす設定では JS で隠さず、帯は完成した位置に止めて常に見せる\n\n動く実物（1 ファイルで完結する HTML）: https://design-vault.pages.dev/designs/motion-sticky-cta/demo.html",
      "en": "Sticky CTA bar — Sticky CTA bar\n\nPin a button bar to the bottom edge of the viewport, about 60px tall, holding one button and one line of copy.\nOnce 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.\nWhen the top of the closing band (`.dv-end`) enters the bottom of the viewport, slide the bar back down and hide it.\nImplement it with a CSS transition and a single passive scroll listener that reads the position; no library.\n\nDo not drop:\n- Stay hidden inside the title block; appear only after one further screen\n- Hide once the closing band arrives, so the bar never covers the final content\n- Keep the button on one line at 375px; truncate the copy rather than wrapping\n- Under reduced motion, never hide it; keep the bar fixed in its final position\n\nA working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-sticky-cta/demo.html"
    },
    "code": {
      "url": "https://design-vault.pages.dev/designs/motion-sticky-cta/demo.html",
      "html": "<!doctype html>\n<html lang=\"ja\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>44 追従 CTA — Sticky CTA bar</title>\n<style>\n/* dv-motion-base:start */\n:root {\n  --ground: #FFFFFF;\n  --surface: #F3F5F8;\n  --ink: #14181F;\n  --muted: #5A6472;\n  --accent: #1F5FA8;\n  --rule: #D8DDE4;\n  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));\n  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));\n  --shade: color-mix(in srgb, var(--ink) 14%, transparent);\n\n  --font: \"Zen Kaku Gothic New\", \"Hiragino Sans\", \"Noto Sans JP\", \"Yu Gothic\", system-ui, sans-serif;\n  --mono: ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n\n  --t-label: 13px;\n  --t-body: 16px;\n  --t-lead: 20px;\n  --t-h2: 32px;\n  --t-h1: clamp(40px, 7vw, 72px);\n\n  --s1: 4px;\n  --s2: 8px;\n  --s3: 16px;\n  --s4: 24px;\n  --s5: 40px;\n  --s6: 64px;\n  --s7: 104px;\n\n  --radius: 12px;\n  --ease: cubic-bezier(.2, .7, .2, 1);\n  --dur: .7s;\n  --measure: 1080px;\n}\n*, *::before, *::after { box-sizing: border-box; }\nhtml { -webkit-text-size-adjust: 100%; }\nbody {\n  margin: 0;\n  background: var(--ground);\n  color: var(--ink);\n  font-family: var(--font);\n  font-size: var(--t-body);\n  line-height: 1.7;\n  font-feature-settings: \"palt\";\n  overflow-x: hidden;\n}\nh1, h2, h3, p, ul, ol, figure { margin: 0; padding: 0; }\nul, ol { list-style: none; }\n.dv-wrap { width: min(100% - 2 * var(--s4), var(--measure)); margin-inline: auto; }\n.dv-label {\n  min-height: 72vh;\n  display: grid;\n  align-content: center;\n  gap: var(--s3);\n  padding-block: var(--s6);\n  border-bottom: 1px solid var(--rule);\n}\n.dv-num { font-family: var(--mono); font-size: var(--t-lead); font-weight: 700; color: var(--accent); letter-spacing: .04em; }\n.dv-name { font-size: var(--t-h1); font-weight: 700; line-height: 1.15; letter-spacing: .01em; }\n.dv-ai { font-family: var(--mono); font-size: var(--t-lead); color: var(--muted); }\n.dv-desc { font-size: var(--t-lead); color: var(--ink); }\n.dv-hint { font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; margin-top: var(--s4); }\n.dv-stage { padding-block: var(--s7); }\n.dv-h2 { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; }\n.dv-lead { font-size: var(--t-lead); color: var(--muted); }\n.dv-eyebrow { font-family: var(--mono); font-size: var(--t-label); color: var(--accent); letter-spacing: .12em; text-transform: uppercase; }\n.dv-card { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); padding: var(--s4); }\n.dv-ph {\n  border-radius: var(--radius);\n  background: linear-gradient(135deg, var(--accent-soft), var(--accent-mid) 60%, var(--accent));\n  aspect-ratio: 16 / 9;\n}\n.dv-ph--alt { background: linear-gradient(200deg, var(--surface), var(--accent-soft) 55%, var(--accent-mid)); }\n.dv-ph--ink { background: linear-gradient(160deg, var(--ink), color-mix(in srgb, var(--ink) 55%, var(--accent))); }\n.dv-end {\n  min-height: 60vh;\n  display: grid;\n  place-items: center;\n  border-top: 1px solid var(--rule);\n  color: var(--muted);\n  font-family: var(--mono);\n  font-size: var(--t-label);\n  letter-spacing: .12em;\n}\n/* dv-motion-base:end */\n\n/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */\n.hd-section { min-height: 80vh; display: grid; align-content: center; }\n.cta {\n  position: fixed;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  z-index: 10;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--s3);\n  padding: var(--s2) var(--s4);\n  background: color-mix(in srgb, var(--ground) 94%, transparent);\n  border-top: 1px solid var(--rule);\n  transition: transform var(--dur) var(--ease), visibility var(--dur);\n}\n.cta-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--t-label); color: var(--muted); }\n.cta-btn {\n  flex-shrink: 0;\n  white-space: nowrap;\n  padding: var(--s2) var(--s4);\n  border-radius: var(--radius);\n  background: var(--accent);\n  color: var(--ground);\n  font-weight: 700;\n  text-decoration: none;\n}\n.js .cta { transform: translateY(100%); visibility: hidden; }\n.js .cta.is-shown { transform: none; visibility: visible; }\n\n@media (prefers-reduced-motion: reduce) {\n  .cta { transition: none; }\n  .js .cta { transform: none; visibility: visible; }\n}\n</style>\n</head>\n<body data-motion-number=\"44\">\n<header class=\"dv-label\">\n  <div class=\"dv-wrap\">\n    <p class=\"dv-num\">44</p>\n    <h1 class=\"dv-name\">追従 CTA</h1>\n    <p class=\"dv-ai\">Sticky CTA bar</p>\n    <p class=\"dv-desc\">途中から下端にボタン帯が現れる</p>\n    <p class=\"dv-hint\">SCROLL ↓</p>\n  </div>\n</header>\n\n<main class=\"dv-stage\">\n  <aside class=\"cta\" aria-label=\"案内\">\n    <p class=\"cta-text\">資料のご請求はこちらから</p>\n    <a class=\"cta-btn\" href=\"#\">無料で始める</a>\n  </aside>\n  <section class=\"hd-section\">\n    <div class=\"dv-wrap\">\n      <p class=\"dv-eyebrow\">Section 1</p>\n      <h2 class=\"dv-h2\">見出しが入ります</h2>\n      <p class=\"dv-lead\">本文のダミーです。名札を抜けて一画面ぶん進むと、下端に帯が現れます。</p>\n    </div>\n  </section>\n  <section class=\"hd-section\">\n    <div class=\"dv-wrap\">\n      <p class=\"dv-eyebrow\">Section 2</p>\n      <h2 class=\"dv-h2\">見出しが入ります</h2>\n      <p class=\"dv-lead\">本文のダミーです。帯は読み進めているあいだ、下端に残ります。</p>\n    </div>\n  </section>\n  <section class=\"hd-section\">\n    <div class=\"dv-wrap\">\n      <p class=\"dv-eyebrow\">Section 3</p>\n      <h2 class=\"dv-h2\">見出しが入ります</h2>\n      <p class=\"dv-lead\">本文のダミーです。終わりの帯が画面に入ると、帯は隠れます。</p>\n    </div>\n  </section>\n  <section class=\"hd-section\">\n    <div class=\"dv-wrap\">\n      <p class=\"dv-eyebrow\">Section 4</p>\n      <h2 class=\"dv-h2\">見出しが入ります</h2>\n      <p class=\"dv-lead\">本文のダミーです。ここまでが演出の舞台です。</p>\n    </div>\n  </section>\n</main>\n\n<footer class=\"dv-end\">END — 44 追従 CTA</footer>\n\n<script>\n/* dv-motion-helpers:start */\nconst dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;\ndocument.documentElement.classList.add('js');\n\n/**\n * 画面に入った要素へ .is-in を付ける。\n * data-reveal=\"repeat\" の要素は画面を出たら外す（何度でも再生）。\n */\nfunction dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {\n  const els = [...document.querySelectorAll(selector)];\n  if (dvReduce || !('IntersectionObserver' in window)) {\n    els.forEach((el) => el.classList.add('is-in'));\n    return;\n  }\n  const io = new IntersectionObserver((entries) => {\n    for (const e of entries) {\n      if (e.isIntersecting) {\n        e.target.classList.add('is-in');\n        if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);\n      } else if (e.target.dataset.reveal === 'repeat') {\n        e.target.classList.remove('is-in');\n      }\n    }\n  }, { threshold, rootMargin });\n  els.forEach((el) => io.observe(el));\n}\n\n/**\n * スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。\n *   data-progress=\"through\" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1\n *   data-progress=\"pin\"     : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1\n *                             （中に position: sticky の箱を持つ、背の高い区画用）\n *   data-progress=\"page\"    : ページ全体のスクロール量\n * onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。\n */\nfunction dvProgress(onUpdate) {\n  const els = [...document.querySelectorAll('[data-progress]')];\n  const clamp = (n) => Math.min(1, Math.max(0, n));\n  let queued = false;\n  const measure = () => {\n    queued = false;\n    const vh = window.innerHeight;\n    for (const el of els) {\n      const r = el.getBoundingClientRect();\n      let p;\n      if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;\n      else if (el.dataset.progress === 'page') {\n        const max = document.documentElement.scrollHeight - vh;\n        p = max > 0 ? window.scrollY / max : 0;\n      } else p = (vh - r.top) / (vh + r.height);\n      p = clamp(p);\n      el.style.setProperty('--p', p.toFixed(4));\n      if (onUpdate) onUpdate(el, p);\n    }\n  };\n  const request = () => {\n    if (!queued) { queued = true; requestAnimationFrame(measure); }\n  };\n  addEventListener('scroll', request, { passive: true });\n  addEventListener('resize', request);\n  measure();\n}\n/* dv-motion-helpers:end */\n\n/* ---- この演出だけの JS ---- */\nconst dvCta = document.querySelector('.cta');\nconst dvLabel = document.querySelector('.dv-label');\nconst dvEnd = document.querySelector('.dv-end');\nlet dvQueued = false;\n\n/** 名札を抜けて一画面ぶん進んだら帯を出し、終わりの帯が入ったら隠す。 */\nfunction dvCtaMeasure() {\n  dvQueued = false;\n  const vh = innerHeight;\n  const past = dvLabel.getBoundingClientRect().bottom <= -vh;\n  const atEnd = dvEnd.getBoundingClientRect().top < vh;\n  dvCta.classList.toggle('is-shown', past && !atEnd);\n}\n\nif (!dvReduce) {\n  addEventListener('scroll', () => {\n    if (!dvQueued) { dvQueued = true; requestAnimationFrame(dvCtaMeasure); }\n  }, { passive: true });\n  addEventListener('resize', dvCtaMeasure);\n  dvCtaMeasure();\n}\n</script>\n</body>\n</html>\n"
    }
  },
  "license": "Original",
  "catalogVersion": 1,
  "designSchemaVersion": 2,
  "capabilities": [
    "live-preview",
    "copy-prompt",
    "get-code"
  ],
  "reusePolicy": "construction-reuse",
  "urls": {
    "ja": "https://design-vault.pages.dev/designs/motion-sticky-cta",
    "en": "https://design-vault.pages.dev/en/designs/motion-sticky-cta",
    "json": "https://design-vault.pages.dev/ai/assets/motion-sticky-cta.json",
    "designJson": null
  }
}