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)
Linked letters — Connected letters Link the letters of a heading with a single ribbon. Centre a bold 150px heading in an SVG with a 1200 × 300 viewBox and read each character’s extent with getExtentOfChar. Create points alternating between 62% and 32% of the letter height, add end points extended 90px beyond the first and last, and join them smoothly with cubic Béziers whose tangents are horizontal. Paint the ribbon behind the text, 26px wide in var(--accent-mid) with round caps and pathLength="1", and draw it from 10% to 80% of a 340vh pinned section by moving stroke-dashoffset from 1 to 0 with an ease-out, 1 − (1 − t)². Measure the character positions after the webfont has loaded. Keep the real heading in the SVG’s aria-label, and with reduced motion show the ribbon already drawn. Implement it with CSS and JavaScript only, without any library. Do not drop: - Paint the ribbon behind the letters so legibility never drops - Measure character positions after the webfont has loaded - Use one continuous path, not one line per letter - Keep the real heading in aria-label, and rest with the ribbon drawn under reduced motion A working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-linked-letters/demo.html
/designs/motion-linked-letters/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>60 文字を繋げる — Connected letters</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); }
.ll-zone { height: 340vh; }
.ll-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }
@media (prefers-reduced-motion: reduce) {
.ll-zone { height: auto; }
.ll-stick { position: static; height: auto; padding-block: var(--s6); }
}
.ll-svg { display: block; width: 100vw; height: auto; }
.ll-text { font-family: var(--font); font-size: 150px; font-weight: 700; fill: var(--ink); text-anchor: middle; }
.ll-link { fill: none; stroke: var(--accent-mid); stroke-width: 26; stroke-linecap: round; stroke-linejoin: round; }
</style>
</head>
<body data-motion-number="60">
<header class="dv-label">
<div class="dv-wrap">
<p class="dv-num">60</p>
<h1 class="dv-name">文字を繋げる</h1>
<p class="dv-ai">Connected letters</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="ll-zone" data-progress="pin">
<div class="ll-stick">
<svg class="ll-svg" viewBox="0 0 1200 300" role="img" aria-label="文字を繋げる" xmlns="http://www.w3.org/2000/svg">
<path class="ll-link" id="ll-link" pathLength="1" d="" />
<text class="ll-text" x="600" y="200" aria-hidden="true">文字を繋げる</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 — 60 文字を繋げる</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 textEl = document.querySelector('.ll-text');
const link = document.getElementById('ll-link');
const N = [...textEl.textContent].length;
// 各字の外接の中心を拾い、上下に振れる 1 本の曲線で結ぶ。文字の後ろ(SVG の先頭)に描く。
function build() {
const pts = [];
for (let i = 0; i < N; i++) {
const r = textEl.getExtentOfChar(i);
const x = r.x + r.width / 2;
const y = r.y + r.height * (i % 2 === 0 ? 0.62 : 0.32);
pts.push([x, y]);
}
// 端は少し外へ延ばす。
const first = pts[0], last = pts[pts.length - 1];
const all = [[first[0] - 90, first[1] + 40], ...pts, [last[0] + 90, last[1] - 40]];
// 隣り合う点を、水平な接線の 3 次ベジェでつなぐ。
let d = `M${all[0][0].toFixed(1)} ${all[0][1].toFixed(1)}`;
for (let i = 1; i < all.length; i++) {
const [x0, y0] = all[i - 1], [x1, y1] = all[i], mx = (x0 + x1) / 2;
d += ` C${mx.toFixed(1)} ${y0.toFixed(1)} ${mx.toFixed(1)} ${y1.toFixed(1)} ${x1.toFixed(1)} ${y1.toFixed(1)}`;
}
link.setAttribute('d', d);
}
const ease = (t) => 1 - Math.pow(1 - t, 2);
function place(p) {
const t = ease(Math.min(1, Math.max(0, (p - 0.1) / 0.7)));
link.style.strokeDasharray = '1';
link.style.strokeDashoffset = String(1 - t);
link.style.visibility = t > 0 ? 'visible' : 'hidden';
}
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(() => {
build();
if (!dvReduce) dvProgress((el, p) => place(p));
});
</script>
</body>
</html>