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)
Scrollspy — Scrollspy Use 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. Give the sections the ids sec-1 through sec-5, and make each table of contents item an in-page link (href="#sec-n"). Use IntersectionObserver to find the section inside the central 10% band of the viewport, and set aria-current="true" only on its matching item. The 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. Implement with CSS and IntersectionObserver only; no library. Do not drop: - Each table of contents item is an in-page link (#sec-n), and the current item carries aria-current="true" - Decide the current section by a band at the centre of the viewport, not by section top edges - At 375px the table of contents is a fixed horizontal row at the top and never exceeds the width A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-scrollspy/demo.html
/designs/motion-scrollspy/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>24 スクロールスパイ — Scrollspy</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() のみ ---- */
.sp-layout { display: grid; gap: var(--s4); }
.sp-toc {
position: sticky;
top: 0;
z-index: 1;
background: var(--ground);
border-bottom: 1px solid var(--rule);
padding-block: var(--s2);
}
.sp-toc ol { display: flex; justify-content: space-between; gap: var(--s2); }
.sp-toc a {
display: block;
padding-block: var(--s1);
font-size: var(--t-label);
color: var(--muted);
text-decoration: none;
border-bottom: 2px solid transparent;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.sp-toc a[aria-current="true"] { color: var(--accent); border-color: var(--accent); }
.sp-sec {
min-height: 70vh;
display: grid;
align-content: center;
gap: var(--s3);
scroll-margin-top: 64px;
}
@media (min-width: 720px) {
.sp-layout { grid-template-columns: 200px minmax(0, 1fr); align-items: start; gap: var(--s6); }
.sp-toc { top: var(--s5); padding: 0; background: none; border-bottom: 0; }
.sp-toc ol { flex-direction: column; justify-content: flex-start; gap: var(--s2); }
.sp-toc a { border-bottom: 0; border-left: 2px solid transparent; padding: var(--s1) var(--s3); }
.sp-sec { scroll-margin-top: var(--s5); }
}
@media (prefers-reduced-motion: reduce) {
.sp-toc a { transition: none; }
}
</style>
</head>
<body data-motion-number="24">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">24</p>
<h1 class="dv-name">スクロールスパイ</h1>
<p class="dv-ai">Scrollspy</p>
<p class="dv-desc">目次が現在地をハイライト</p>
<p class="dv-hint">SCROLL ↓</p>
</div>
</header>
<main class="dv-stage">
<div class="dv-wrap sp-layout">
<nav class="sp-toc" aria-label="目次">
<ol>
<li><a href="#sec-1">概要</a></li>
<li><a href="#sec-2">仕組み</a></li>
<li><a href="#sec-3">事例</a></li>
<li><a href="#sec-4">料金</a></li>
<li><a href="#sec-5">連絡</a></li>
</ol>
</nav>
<div class="sp-body">
<section class="sp-sec" id="sec-1">
<p class="dv-eyebrow">Section 01</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>
</section>
<section class="sp-sec" id="sec-2">
<p class="dv-eyebrow">Section 02</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>
</section>
<section class="sp-sec" id="sec-3">
<p class="dv-eyebrow">Section 03</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>
</section>
<section class="sp-sec" id="sec-4">
<p class="dv-eyebrow">Section 04</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>
</section>
<section class="sp-sec" id="sec-5">
<p class="dv-eyebrow">Section 05</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。目次の現在地が、読んでいる区画に合わせて変わります。</p>
</section>
</div>
</div>
</main>
<footer class="dv-end">END — 24 スクロールスパイ</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 spLinks = [...document.querySelectorAll('.sp-toc a')];
const spMark = (id, on) => {
for (const a of spLinks) {
if (a.getAttribute('href') !== `#${id}`) continue;
if (on) a.setAttribute('aria-current', 'true');
else a.removeAttribute('aria-current');
}
};
// 画面の中央 10% の帯に入っている区画が現在地になる。
const spIO = new IntersectionObserver((entries) => {
for (const e of entries) spMark(e.target.id, e.isIntersecting);
}, { rootMargin: '-45% 0px -45% 0px' });
document.querySelectorAll('.sp-sec').forEach((s) => spIO.observe(s));
</script>
</body>
</html>