{
  "id": "motion:motion-scrollspy",
  "slug": "motion-scrollspy",
  "kind": "motion",
  "title": {
    "ja": "スクロールスパイ",
    "en": "Scrollspy"
  },
  "aiName": "Scrollspy",
  "summary": {
    "ja": "目次が現在地をハイライト",
    "en": "The table of contents highlights the current section"
  },
  "number": 24,
  "group": "pin-nav",
  "techniques": [
    "intersection-observer"
  ],
  "rules": [
    {
      "ja": "目次の項目は必ずページ内リンク（#sec-n）にし、現在地には aria-current=\"true\" を付ける",
      "en": "Each table of contents item is an in-page link (#sec-n), and the current item carries aria-current=\"true\""
    },
    {
      "ja": "現在地は画面中央の帯で決める。区画の上端では切り替えない",
      "en": "Decide the current section by a band at the centre of the viewport, not by section top edges"
    },
    {
      "ja": "375px 幅では目次を上端に横一列で固定し、画面幅を超えない",
      "en": "At 375px the table of contents is a fixed horizontal row at the top and never exceeds the width"
    }
  ],
  "reproduction": {
    "prompt": {
      "ja": "スクロールスパイ — Scrollspy\n\n2 列のレイアウトで、左に目次（sticky で画面上端から 40px 下に固定）、右に高さ 70vh 以上の区画を 5 つ並べる。\n区画の id は sec-1 から sec-5、目次の各項目は href=\"#sec-n\" のページ内リンクにする。\nIntersectionObserver で、画面の中央 10% の帯に入った区画を求め、対応する目次項目にだけ aria-current=\"true\" を付ける。\n現在地の項目は文字色と左の 2px 線を --accent にする。375px 幅では目次を画面上端に横一列で固定し、区画はその下に続ける。\nCSS と IntersectionObserver だけで実装し、ライブラリは使わない。\n\n外すと崩れる点:\n- 目次の項目は必ずページ内リンク（#sec-n）にし、現在地には aria-current=\"true\" を付ける\n- 現在地は画面中央の帯で決める。区画の上端では切り替えない\n- 375px 幅では目次を上端に横一列で固定し、画面幅を超えない\n\n動く実物（1 ファイルで完結する HTML）: https://design-vault.pages.dev/designs/motion-scrollspy/demo.html",
      "en": "Scrollspy — Scrollspy\n\nUse a two-column layout: a table of contents on the left, sticky 40px below the top edge, and five sections of at least 70vh on the right.\nGive the sections the ids sec-1 through sec-5, and make each table of contents item an in-page link (href=\"#sec-n\").\nUse IntersectionObserver to find the section inside the central 10% band of the viewport, and set aria-current=\"true\" only on its matching item.\nThe current item takes the accent colour for its text and its 2px left rule. At 375px width, fix the table of contents as one horizontal row at the top edge and let the sections continue below it.\nImplement with CSS and IntersectionObserver only; no library.\n\nDo not drop:\n- Each table of contents item is an in-page link (#sec-n), and the current item carries aria-current=\"true\"\n- Decide the current section by a band at the centre of the viewport, not by section top edges\n- At 375px the table of contents is a fixed horizontal row at the top and never exceeds the width\n\nA working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-scrollspy/demo.html"
    },
    "code": {
      "url": "https://design-vault.pages.dev/designs/motion-scrollspy/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>24 スクロールスパイ — Scrollspy</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.sp-layout { display: grid; gap: var(--s4); }\n.sp-toc {\n  position: sticky;\n  top: 0;\n  z-index: 1;\n  background: var(--ground);\n  border-bottom: 1px solid var(--rule);\n  padding-block: var(--s2);\n}\n.sp-toc ol { display: flex; justify-content: space-between; gap: var(--s2); }\n.sp-toc a {\n  display: block;\n  padding-block: var(--s1);\n  font-size: var(--t-label);\n  color: var(--muted);\n  text-decoration: none;\n  border-bottom: 2px solid transparent;\n  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);\n}\n.sp-toc a[aria-current=\"true\"] { color: var(--accent); border-color: var(--accent); }\n.sp-sec {\n  min-height: 70vh;\n  display: grid;\n  align-content: center;\n  gap: var(--s3);\n  scroll-margin-top: 64px;\n}\n\n@media (min-width: 720px) {\n  .sp-layout { grid-template-columns: 200px minmax(0, 1fr); align-items: start; gap: var(--s6); }\n  .sp-toc { top: var(--s5); padding: 0; background: none; border-bottom: 0; }\n  .sp-toc ol { flex-direction: column; justify-content: flex-start; gap: var(--s2); }\n  .sp-toc a { border-bottom: 0; border-left: 2px solid transparent; padding: var(--s1) var(--s3); }\n  .sp-sec { scroll-margin-top: var(--s5); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .sp-toc a { transition: none; }\n}\n</style>\n</head>\n<body data-motion-number=\"24\">\n<header class=\"dv-label\">\n  <div class=\"dv-wrap\">\n    <p class=\"dv-num\">24</p>\n    <h1 class=\"dv-name\">スクロールスパイ</h1>\n    <p class=\"dv-ai\">Scrollspy</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  <div class=\"dv-wrap sp-layout\">\n    <nav class=\"sp-toc\" aria-label=\"目次\">\n      <ol>\n        <li><a href=\"#sec-1\">概要</a></li>\n        <li><a href=\"#sec-2\">仕組み</a></li>\n        <li><a href=\"#sec-3\">事例</a></li>\n        <li><a href=\"#sec-4\">料金</a></li>\n        <li><a href=\"#sec-5\">連絡</a></li>\n      </ol>\n    </nav>\n    <div class=\"sp-body\">\n      <section class=\"sp-sec\" id=\"sec-1\">\n        <p class=\"dv-eyebrow\">Section 01</p>\n        <h2 class=\"dv-h2\">見出しが入ります</h2>\n        <p class=\"dv-lead\">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>\n      </section>\n      <section class=\"sp-sec\" id=\"sec-2\">\n        <p class=\"dv-eyebrow\">Section 02</p>\n        <h2 class=\"dv-h2\">見出しが入ります</h2>\n        <p class=\"dv-lead\">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>\n      </section>\n      <section class=\"sp-sec\" id=\"sec-3\">\n        <p class=\"dv-eyebrow\">Section 03</p>\n        <h2 class=\"dv-h2\">見出しが入ります</h2>\n        <p class=\"dv-lead\">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>\n      </section>\n      <section class=\"sp-sec\" id=\"sec-4\">\n        <p class=\"dv-eyebrow\">Section 04</p>\n        <h2 class=\"dv-h2\">見出しが入ります</h2>\n        <p class=\"dv-lead\">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>\n      </section>\n      <section class=\"sp-sec\" id=\"sec-5\">\n        <p class=\"dv-eyebrow\">Section 05</p>\n        <h2 class=\"dv-h2\">見出しが入ります</h2>\n        <p class=\"dv-lead\">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>\n      </section>\n    </div>\n  </div>\n</main>\n\n<footer class=\"dv-end\">END — 24 スクロールスパイ</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 spLinks = [...document.querySelectorAll('.sp-toc a')];\nconst spMark = (id, on) => {\n  for (const a of spLinks) {\n    if (a.getAttribute('href') !== `#${id}`) continue;\n    if (on) a.setAttribute('aria-current', 'true');\n    else a.removeAttribute('aria-current');\n  }\n};\n// 画面の中央 10% の帯に入っている区画が現在地になる。\nconst spIO = new IntersectionObserver((entries) => {\n  for (const e of entries) spMark(e.target.id, e.isIntersecting);\n}, { rootMargin: '-45% 0px -45% 0px' });\ndocument.querySelectorAll('.sp-sec').forEach((s) => spIO.observe(s));\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-scrollspy",
    "en": "https://design-vault.pages.dev/en/designs/motion-scrollspy",
    "json": "https://design-vault.pages.dev/ai/assets/motion-scrollspy.json",
    "designJson": null
  }
}