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)
Timeline progress fill — Timeline progress fill Build a vertical timeline of five steps, each occupying 40vh of the viewport height. A thin 2px track sits behind an accent-coloured fill of the same width, and the fill grows from top to bottom with `transform: scaleY(var(--p))`, where --p runs from 0 to 1 over the scroll progress of the whole section. When the fill's leading edge passes the centre of step i (at (i + 0.5) / 5 of the section height), add `.is-on` to that step's round node, switching it from the ground colour to the accent over 0.7 seconds. Above 720px, place the line at the centre and alternate the step text left and right; at 375px, pull the line to the left edge and stack the text in one column. Implement with CSS and plain JavaScript only; do not use a library. Do not drop: - The fill grows in proportion to --p, and steps below its leading edge stay unlit - A step lights only after the line has passed its centre, never before - At 375px the track and nodes sit at the left edge with no horizontal scroll - With reduced motion, the line stays at full length and every step stays lit A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-timeline-fill/demo.html
/designs/motion-timeline-fill/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>35 タイムライン — Timeline progress fill</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() のみ ---- */
.tl { position: relative; height: 200vh; }
.tl-track, .tl-fill {
position: absolute;
top: 0;
bottom: 0;
left: 11px;
width: 2px;
}
.tl-track { background: var(--rule); }
.tl-fill {
background: var(--accent);
transform-origin: top;
transform: scaleY(var(--p, 0));
}
.tl-step {
position: relative;
height: 40vh;
display: grid;
align-content: center;
padding-inline-start: var(--s6);
}
.tl-body { display: grid; gap: var(--s2); }
.tl-node {
position: absolute;
left: 0;
top: 50%;
width: 24px;
height: 24px;
margin-top: -12px;
border-radius: 50%;
border: 2px solid var(--rule);
background: var(--ground);
transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.tl-node.is-on { background: var(--accent); border-color: var(--accent); }
@media (min-width: 720px) {
.tl-track, .tl-fill { left: 50%; margin-left: -1px; }
.tl-step { padding-inline-start: 0; grid-template-columns: 1fr 1fr; column-gap: var(--s6); }
.tl-step .tl-body { grid-column: 1; }
.tl-step:nth-child(even) .tl-body { grid-column: 2; }
.tl-step:nth-child(odd) .tl-body { text-align: end; }
.tl-node { left: 50%; margin-left: -12px; }
}
html:not(.js) .tl-fill { transform: none; }
@media (prefers-reduced-motion: reduce) {
.tl-fill { transform: none; }
.tl-node { transition: none; }
}
</style>
</head>
<body data-motion-number="35">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">35</p>
<h1 class="dv-name">タイムライン</h1>
<p class="dv-ai">Timeline progress fill</p>
<p class="dv-desc">縦線が伸び、各段が灯る</p>
<p class="dv-hint">SCROLL ↓</p>
</div>
</header>
<main class="dv-stage">
<div class="dv-wrap">
<ol class="tl" data-progress="through">
<span class="tl-track" aria-hidden="true"></span>
<span class="tl-fill" aria-hidden="true"></span>
<li class="tl-step">
<span class="tl-node" aria-hidden="true"></span>
<div class="tl-body">
<p class="dv-eyebrow">Step 01</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。線が届いた段から灯ります。</p>
</div>
</li>
<li class="tl-step">
<span class="tl-node" aria-hidden="true"></span>
<div class="tl-body">
<p class="dv-eyebrow">Step 02</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。段が増えても同じ線を進みます。</p>
</div>
</li>
<li class="tl-step">
<span class="tl-node" aria-hidden="true"></span>
<div class="tl-body">
<p class="dv-eyebrow">Step 03</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。中ほどの段です。</p>
</div>
</li>
<li class="tl-step">
<span class="tl-node" aria-hidden="true"></span>
<div class="tl-body">
<p class="dv-eyebrow">Step 04</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。最後から二番目の段です。</p>
</div>
</li>
<li class="tl-step">
<span class="tl-node" aria-hidden="true"></span>
<div class="tl-body">
<p class="dv-eyebrow">Step 05</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。最後の段に届くと、線が全長になります。</p>
</div>
</li>
</ol>
</div>
</main>
<footer class="dv-end">END — 35 タイムライン</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 ---- */
// 線の先端が段の中央((i + 0.5) / 5)を越えたら、その段のノードを灯す。
dvProgress((el, p) => {
el.querySelectorAll('.tl-node').forEach((node, i) => {
node.classList.toggle('is-on', dvReduce || p >= (i + 0.5) / 5);
});
});
</script>
</body>
</html>