{
  "id": "motion:motion-scroll-snap",
  "slug": "motion-scroll-snap",
  "kind": "motion",
  "title": {
    "ja": "スクロールスナップ",
    "en": "Scroll snap"
  },
  "aiName": "CSS scroll snap",
  "summary": {
    "ja": "1画面ずつピタッと止まる",
    "en": "Snaps to one full screen at a time"
  },
  "number": 21,
  "group": "pin-nav",
  "techniques": [
    "scroll-snap",
    "intersection-observer"
  ],
  "rules": [
    {
      "ja": "区画は全部 100vh。高さが違うと吸着先がずれて途中で止まる",
      "en": "Every section is exactly 100vh; a different height puts the snap in the wrong place"
    },
    {
      "ja": "名札と末尾の帯も区画として scroll-snap-align: start を付ける",
      "en": "The title block and closing band are sections too and carry scroll-snap-align: start"
    },
    {
      "ja": "動きを減らす設定では mandatory を proximity に弱め、吸着を強制しない",
      "en": "Under reduced motion, relax mandatory to proximity so the scroll is never forced"
    }
  ],
  "reproduction": {
    "prompt": {
      "ja": "スクロールスナップ — CSS scroll snap\n\nhtml に scroll-snap-type: y mandatory を付け、縦スクロールが各画面の上端（scroll-snap-align: start）で止まるようにする。\n区画は名札・本文 3 画面・末尾の帯の計 5 つで、どれも高さ 100vh。\nスクロールを離すと最も近い区画の上端へ吸着し、区画の途中で止まることはない。\n本文の各画面は、吸着して見えた時点で図版が 0.7 秒の ease でふわっと現れる（IntersectionObserver で is-in を付ける）。\n動きを減らす設定では scroll-snap-type を y proximity に弱める。CSS と IntersectionObserver だけで実装し、ライブラリは使わない。\n\n外すと崩れる点:\n- 区画は全部 100vh。高さが違うと吸着先がずれて途中で止まる\n- 名札と末尾の帯も区画として scroll-snap-align: start を付ける\n- 動きを減らす設定では mandatory を proximity に弱め、吸着を強制しない\n\n動く実物（1 ファイルで完結する HTML）: https://design-vault.pages.dev/designs/motion-scroll-snap/demo.html",
      "en": "Scroll snap — CSS scroll snap\n\nApply scroll-snap-type: y mandatory to html so vertical scrolling settles on the top edge of each screen (scroll-snap-align: start).\nThere are five sections (title, three content screens, closing band), each exactly 100vh tall.\nWhen the user releases the scroll, the page snaps to the nearest section top and never rests mid-section.\nOnce a content screen snaps into view, its image fades in over 0.7s with an ease curve, using IntersectionObserver to add is-in.\nUnder prefers-reduced-motion: reduce, weaken the snap to scroll-snap-type: y proximity. Implement with CSS and IntersectionObserver only; no library.\n\nDo not drop:\n- Every section is exactly 100vh; a different height puts the snap in the wrong place\n- The title block and closing band are sections too and carry scroll-snap-align: start\n- Under reduced motion, relax mandatory to proximity so the scroll is never forced\n\nA working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-scroll-snap/demo.html"
    },
    "code": {
      "url": "https://design-vault.pages.dev/designs/motion-scroll-snap/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>21 スクロールスナップ — CSS scroll snap</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() のみ ---- */\nhtml { scroll-snap-type: y mandatory; }\n.dv-label,\n.dv-end,\n.snap { min-height: 100vh; scroll-snap-align: start; }\n.dv-stage { padding-block: 0; }\n.snap { display: grid; align-items: center; }\n.snap--alt { background: var(--surface); }\n.snap-body { display: grid; gap: var(--s3); }\n.snap .dv-ph { aspect-ratio: auto; height: min(44vh, 420px); }\n.js [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease); }\n.js [data-reveal].is-in { opacity: 1; transform: none; }\n\n@media (prefers-reduced-motion: reduce) {\n  html { scroll-snap-type: y proximity; }\n  .js [data-reveal] { opacity: 1; transform: none; transition: none; }\n}\n</style>\n</head>\n<body data-motion-number=\"21\">\n<header class=\"dv-label\">\n  <div class=\"dv-wrap\">\n    <p class=\"dv-num\">21</p>\n    <h1 class=\"dv-name\">スクロールスナップ</h1>\n    <p class=\"dv-ai\">CSS scroll snap</p>\n    <p class=\"dv-desc\">1画面ずつピタッと止まる</p>\n    <p class=\"dv-hint\">SCROLL ↓</p>\n  </div>\n</header>\n\n<main class=\"dv-stage\">\n  <section class=\"snap\">\n    <div class=\"dv-wrap snap-body\">\n      <p class=\"dv-eyebrow\">Screen 1 / 3</p>\n      <h2 class=\"dv-h2\">見出しが入ります</h2>\n      <p class=\"dv-lead\">本文のダミーです。区画の上端に止まると、この画面の中身が表示されます。</p>\n      <div class=\"dv-ph\" data-reveal></div>\n    </div>\n  </section>\n  <section class=\"snap snap--alt\">\n    <div class=\"dv-wrap snap-body\">\n      <p class=\"dv-eyebrow\">Screen 2 / 3</p>\n      <h2 class=\"dv-h2\">見出しが入ります</h2>\n      <p class=\"dv-lead\">本文のダミーです。指を離すと、いちばん近い画面の上端へ吸着します。</p>\n      <div class=\"dv-ph dv-ph--alt\" data-reveal></div>\n    </div>\n  </section>\n  <section class=\"snap\">\n    <div class=\"dv-wrap snap-body\">\n      <p class=\"dv-eyebrow\">Screen 3 / 3</p>\n      <h2 class=\"dv-h2\">見出しが入ります</h2>\n      <p class=\"dv-lead\">本文のダミーです。画面の途中で止まることはありません。</p>\n      <div class=\"dv-ph dv-ph--ink\" data-reveal></div>\n    </div>\n  </section>\n</main>\n\n<footer class=\"dv-end\">END — 21 スクロールスナップ</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 ---- */\ndvReveal();\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-scroll-snap",
    "en": "https://design-vault.pages.dev/en/designs/motion-scroll-snap",
    "json": "https://design-vault.pages.dev/ai/assets/motion-scroll-snap.json",
    "designJson": null
  }
}