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)
Stroke order — Stroke-order handwriting Write the two characters 日 (4 strokes) and 本 (5 strokes) one stroke at a time, in correct stroke order, as the page scrolls. Draw each stroke as an SVG path (stroke-linecap: round, stroke-width 7) with pathLength="1", keeping stroke-dasharray: 1 and moving stroke-dashoffset from 1 to 0. Use 8% to 86% of the progress through a 420vh pinned section, and give each stroke a share of it proportional to its real length so longer strokes take longer. Put an accent-coloured dot at the tip of the stroke being written, and show the stroke count below as "筆順 05 / 09". Lay a faint guide of the strokes and a dotted cross under each character, and with JavaScript off or reduced motion show all nine strokes already written. Implement it with CSS and JavaScript only, without any library. Do not drop: - Start each path at the point where the brush lands; drawing from the other end reverses the stroke direction - The order of strokes is the stroke order; finish one stroke before the next begins - A zero-length line still leaves a round-cap dot, so keep unwritten strokes visibility: hidden - With reduced motion, stop with every stroke already written A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-stroke-order/demo.html
/designs/motion-stroke-order/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>45 筆順 — Stroke-order handwriting</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() のみ ---- */
.stack { display: grid; gap: var(--s6); }
.so-zone { height: 420vh; }
.so-stick {
position: sticky; top: 0; height: 100vh;
display: grid; place-content: center; justify-items: center; gap: var(--s4);
}
/* 幅は画面から決める。中央寄せの grid の中で % を使うと箱が点まで縮む。 */
.so-board { display: block; width: min(100vw - 2 * var(--s4), 760px); height: auto; overflow: visible; }
.so-guide { fill: none; stroke: color-mix(in srgb, var(--ink) 11%, var(--ground)); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
.so-ink { fill: none; stroke: var(--ink); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
.so-grid { fill: none; stroke: var(--rule); stroke-width: 1; stroke-dasharray: 4 5; }
.so-frame { fill: none; stroke: var(--rule); stroke-width: 1.5; }
.so-tip { fill: var(--accent); opacity: 0; }
.so-count { font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; }
.so-count b { color: var(--accent); font-weight: 700; }
@media (prefers-reduced-motion: reduce) {
.so-zone { height: auto; }
.so-stick { position: static; height: auto; padding-block: var(--s6); }
.so-tip { display: none; }
}
</style>
</head>
<body data-motion-number="45">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">45</p>
<h1 class="dv-name">筆順</h1>
<p class="dv-ai">Stroke-order handwriting</p>
<p class="dv-desc">文字が一画ずつ、筆順どおりに書かれる</p>
<p class="dv-hint">SCROLL ↓</p>
</div>
</header>
<main class="dv-stage">
<div class="dv-wrap stack">
<section class="dv-card">
<p class="dv-eyebrow">Section 1</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。スクロールすると、下の二文字が一画ずつ筆順どおりに書かれていきます。</p>
</section>
</div>
<section class="so-zone" data-progress="pin">
<div class="so-stick">
<svg class="so-board" viewBox="0 0 240 120" role="img" aria-label="日本(にほん)" xmlns="http://www.w3.org/2000/svg">
<g class="so-frames">
<rect class="so-frame" x="2" y="10" width="104" height="104" rx="4"/>
<rect class="so-frame" x="134" y="10" width="104" height="104" rx="4"/>
<path class="so-grid" d="M54 10V114M2 62H106M186 10V114M134 62H238"/>
</g>
<!-- 日(4 画)。各画の始点が筆の入り -->
<g class="so-char" transform="translate(4 12)">
<path class="so-guide" d="M28 16V84"/><path class="so-guide" d="M28 16H72V84"/><path class="so-guide" d="M28 50H72"/><path class="so-guide" d="M28 84H72"/>
</g>
<!-- 本(5 画) -->
<g class="so-char" transform="translate(136 12)">
<path class="so-guide" d="M12 34H88"/><path class="so-guide" d="M50 10V92"/><path class="so-guide" d="M47 42Q36 64 12 80"/><path class="so-guide" d="M53 42Q64 64 88 80"/><path class="so-guide" d="M32 73H68"/>
</g>
<g class="so-draw" transform="translate(4 12)">
<path class="so-ink" pathLength="1" d="M28 16V84"/><path class="so-ink" pathLength="1" d="M28 16H72V84"/><path class="so-ink" pathLength="1" d="M28 50H72"/><path class="so-ink" pathLength="1" d="M28 84H72"/>
</g>
<g class="so-draw" transform="translate(136 12)">
<path class="so-ink" pathLength="1" d="M12 34H88"/><path class="so-ink" pathLength="1" d="M50 10V92"/><path class="so-ink" pathLength="1" d="M47 42Q36 64 12 80"/><path class="so-ink" pathLength="1" d="M53 42Q64 64 88 80"/><path class="so-ink" pathLength="1" d="M32 73H68"/>
</g>
<circle class="so-tip" r="4.5" cx="0" cy="0"/>
</svg>
<p class="so-count">筆順 <b>09</b> / 09</p>
</div>
</section>
<div class="dv-wrap stack">
<section class="dv-card">
<p class="dv-eyebrow">Section 2</p>
<h2 class="dv-h2">見出しが入ります</h2>
<p class="dv-lead">本文のダミーです。書き終えた文字は、そのまま残ります。</p>
</section>
</div>
</main>
<footer class="dv-end">END — 45 筆順</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 board = document.querySelector('.so-board');
const tip = board.querySelector('.so-tip');
const countNo = document.querySelector('.so-count b');
const inks = [...board.querySelectorAll('.so-ink')];
const lens = inks.map((p) => p.getTotalLength());
// 画ごとの長さに比例して進みを配る。長い画ほど、書くのに長くかかる。
const total = lens.reduce((a, b) => a + b, 0);
const ends = [];
lens.reduce((acc, len, i) => (ends[i] = (acc + len) / total, acc + len), 0);
function draw(p) {
let done = 0;
inks.forEach((path, i) => {
const start = i === 0 ? 0 : ends[i - 1];
const t = Math.min(1, Math.max(0, (p - start) / (ends[i] - start)));
path.style.strokeDasharray = '1';
path.style.strokeDashoffset = String(1 - t);
// 長さ 0 の線も丸い端の点が残るので、書き始める前は隠す。
path.style.visibility = t > 0 ? 'visible' : 'hidden';
if (t >= 1) done = i + 1;
if (t > 0 && t < 1) {
// 筆先の点を、いま書いている画の先端へ。
const pt = path.getPointAtLength(lens[i] * t);
// 点は、その画と同じ座標系(親の <g>)へ移して置く。
if (tip.parentNode !== path.parentNode) path.parentNode.append(tip);
tip.setAttribute('cx', pt.x.toFixed(2));
tip.setAttribute('cy', pt.y.toFixed(2));
tip.style.opacity = '1';
done = i + 1;
}
});
if (p <= 0 || p >= 1) tip.style.opacity = '0';
countNo.textContent = String(done).padStart(2, '0');
}
if (dvReduce) {
countNo.textContent = String(inks.length).padStart(2, '0');
} else {
// 書き始める前に少し間を置き、書き終えたあとも少し残す。
dvProgress((el, p) => draw(Math.min(1, Math.max(0, (p - 0.08) / 0.78))));
}
</script>
</body>
</html>