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)
Text on a path — Text on a path Make a sentence flow along a winding path as the page scrolls. In an SVG with a 1200 × 560 viewBox, draw a dotted S-shaped path that runs past both side edges, and set one bold sentence (64px) on it with textPath. In a 360vh pinned section set startOffset to "path length − p × (path length + text length)" so the head of the sentence enters at the path’s end and the tail leaves at its start. Colour one word of the sentence with the accent. The text length depends on the font, so measure it with getComputedTextLength after the font has loaded. Keep the real sentence in the SVG’s aria-label, and with reduced motion stop with the whole sentence resting on the path. Implement it with CSS and JavaScript only, without any library. Do not drop: - Drive startOffset from progress only; p = 0 is the sentence head at the path end - Measure the text length after the webfont loads, or the exit point drifts - Run the path past both screen edges so the sentence is never visibly cut mid-curve - Keep the real text in aria-label, and rest with the whole sentence on the path under reduced motion A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-text-on-path/demo.html
/designs/motion-text-on-path/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>49 曲線に沿う文字 — Text on a path</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); }
.tp-zone { height: 360vh; }
.tp-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; }
/* 幅は画面から決める。 */
.tp-svg { display: block; width: 100vw; height: auto; max-height: 90vh; }
.tp-rail { fill: none; stroke: var(--rule); stroke-width: 2; stroke-dasharray: 2 10; stroke-linecap: round; }
.tp-text { font-family: var(--font); font-size: 64px; font-weight: 700; fill: var(--ink); letter-spacing: .04em; }
.tp-text .tp-em { fill: var(--accent); }
@media (prefers-reduced-motion: reduce) {
.tp-zone { height: auto; }
.tp-stick { position: static; height: auto; padding-block: var(--s6); }
}
</style>
</head>
<body data-motion-number="49">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">49</p>
<h1 class="dv-name">曲線に沿う文字</h1>
<p class="dv-ai">Text on a path</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="tp-zone" data-progress="pin">
<div class="tp-stick">
<svg class="tp-svg" viewBox="0 0 1200 560" role="img" aria-label="言葉は曲がりくねった道をたどって、遠くへ届く。" xmlns="http://www.w3.org/2000/svg">
<path id="tp-rail" class="tp-rail" d="M-40 440 C 160 440, 220 120, 420 120 S 640 440, 820 440 S 1040 160, 1240 120"/>
<text class="tp-text" aria-hidden="true"><textPath href="#tp-rail" startOffset="8%">言葉は曲がりくねった道をたどって、<tspan class="tp-em">遠くへ</tspan>届く。</textPath></text>
</svg>
</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 — 49 曲線に沿う文字</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 tp = document.querySelector('.tp-text textPath');
const rail = document.getElementById('tp-rail');
const textEl = document.querySelector('.tp-text');
const railLen = rail.getTotalLength();
let textLen = textEl.getComputedTextLength();
// 文の頭が道の終点にあるところから、文の最後が道の始点を抜けるまで。
function place(p) {
tp.setAttribute('startOffset', (railLen - p * (railLen + textLen)).toFixed(1));
}
// 文の長さは書体で変わるので、書体が読み込まれてから測る。JS が動かないときは、
// マークアップの startOffset(8%)のまま、文が道に乗って読める。
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(() => {
textLen = textEl.getComputedTextLength();
if (dvReduce) {
// 動きを減らす設定では、文全体が道に乗った位置で止める。
tp.setAttribute('startOffset', Math.max(0, (railLen - textLen) / 2).toFixed(1));
} else {
dvProgress((el, p) => place(p));
}
});
</script>
</body>
</html>