{
  "id": "motion:motion-linked-letters",
  "slug": "motion-linked-letters",
  "kind": "motion",
  "title": {
    "ja": "文字を繋げる",
    "en": "Linked letters"
  },
  "aiName": "Connected letters",
  "summary": {
    "ja": "文字と文字のあいだを一本の線がつなぐ",
    "en": "A single ribbon winds through a heading and links its letters"
  },
  "number": 60,
  "group": "text-motion",
  "techniques": [
    "svg",
    "scroll-progress"
  ],
  "rules": [
    {
      "ja": "線は文字の後ろに描く。文字の可読性を落とさない",
      "en": "Paint the ribbon behind the letters so legibility never drops"
    },
    {
      "ja": "字の位置は書体が読み込まれてから測る",
      "en": "Measure character positions after the webfont has loaded"
    },
    {
      "ja": "線は 1 本の path。字ごとに別の線にしない",
      "en": "Use one continuous path, not one line per letter"
    },
    {
      "ja": "本物の見出しは aria-label に残し、動きを減らす設定では描き終えた状態で止める",
      "en": "Keep the real heading in aria-label, and rest with the ribbon drawn under reduced motion"
    }
  ],
  "reproduction": {
    "prompt": {
      "ja": "文字を繋げる — Connected letters\n\n見出しの文字と文字を、一本の線でつなぐ動きを作ってください。SVG（viewBox 1200 × 300）に太字の見出し（150px）を中央寄せで置き、各字の外接の中心を getExtentOfChar で拾います。字の高さの 62% と 32% を交互に通る点をつくり、前後に 90px ずつ延ばした両端を加えて、水平な接線の 3 次ベジェでなめらかにつなぎます。この線は文字の後ろに、幅 26px・var(--accent-mid) の丸い端で描き、pathLength=\"1\" を使って 340vh の pin 区画の進み 10% から 80% で、stroke-dashoffset を 1 → 0 へ easeOut（1 − (1 − t)²）で引きます。書体が読み込まれてから字の位置を測ります。本物の見出しは SVG の aria-label に残し、動きを減らす設定では線を引き終えた状態で見せます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。\n\n外すと崩れる点:\n- 線は文字の後ろに描く。文字の可読性を落とさない\n- 字の位置は書体が読み込まれてから測る\n- 線は 1 本の path。字ごとに別の線にしない\n- 本物の見出しは aria-label に残し、動きを減らす設定では描き終えた状態で止める\n\n動く実物（1 ファイルで完結する HTML）: https://design-vault.pages.dev/designs/motion-linked-letters/demo.html",
      "en": "Linked letters — Connected letters\n\nLink 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.\n\nDo not drop:\n- Paint the ribbon behind the letters so legibility never drops\n- Measure character positions after the webfont has loaded\n- Use one continuous path, not one line per letter\n- Keep the real heading in aria-label, and rest with the ribbon drawn under reduced motion\n\nA working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-linked-letters/demo.html"
    },
    "code": {
      "url": "https://design-vault.pages.dev/designs/motion-linked-letters/demo.html",
      "html": "<!doctype html>\n<html lang=\"ja\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>60 文字を繋げる — Connected letters</title>\n<style>\n/* dv-motion-base:start */\n:root {\n  --ground: #FFFFFF;\n  --surface: #F3F5F8;\n  --ink: #14181F;\n  --muted: #5A6472;\n  --accent: #1F5FA8;\n  --rule: #D8DDE4;\n  --accent-soft: color-mix(in srgb, var(--accent) 12%, var(--ground));\n  --accent-mid: color-mix(in srgb, var(--accent) 45%, var(--ground));\n  --shade: color-mix(in srgb, var(--ink) 14%, transparent);\n\n  --font: \"Zen Kaku Gothic New\", \"Hiragino Sans\", \"Noto Sans JP\", \"Yu Gothic\", system-ui, sans-serif;\n  --mono: ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n\n  --t-label: 13px;\n  --t-body: 16px;\n  --t-lead: 20px;\n  --t-h2: 32px;\n  --t-h1: clamp(40px, 7vw, 72px);\n\n  --s1: 4px;\n  --s2: 8px;\n  --s3: 16px;\n  --s4: 24px;\n  --s5: 40px;\n  --s6: 64px;\n  --s7: 104px;\n\n  --radius: 12px;\n  --ease: cubic-bezier(.2, .7, .2, 1);\n  --dur: .7s;\n  --measure: 1080px;\n}\n*, *::before, *::after { box-sizing: border-box; }\nhtml { -webkit-text-size-adjust: 100%; }\nbody {\n  margin: 0;\n  background: var(--ground);\n  color: var(--ink);\n  font-family: var(--font);\n  font-size: var(--t-body);\n  line-height: 1.7;\n  font-feature-settings: \"palt\";\n  overflow-x: hidden;\n}\nh1, h2, h3, p, ul, ol, figure { margin: 0; padding: 0; }\nul, ol { list-style: none; }\n.dv-wrap { width: min(100% - 2 * var(--s4), var(--measure)); margin-inline: auto; }\n.dv-label {\n  min-height: 72vh;\n  display: grid;\n  align-content: center;\n  gap: var(--s3);\n  padding-block: var(--s6);\n  border-bottom: 1px solid var(--rule);\n}\n.dv-num { font-family: var(--mono); font-size: var(--t-lead); font-weight: 700; color: var(--accent); letter-spacing: .04em; }\n.dv-name { font-size: var(--t-h1); font-weight: 700; line-height: 1.15; letter-spacing: .01em; }\n.dv-ai { font-family: var(--mono); font-size: var(--t-lead); color: var(--muted); }\n.dv-desc { font-size: var(--t-lead); color: var(--ink); }\n.dv-hint { font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; margin-top: var(--s4); }\n.dv-stage { padding-block: var(--s7); }\n.dv-h2 { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; }\n.dv-lead { font-size: var(--t-lead); color: var(--muted); }\n.dv-eyebrow { font-family: var(--mono); font-size: var(--t-label); color: var(--accent); letter-spacing: .12em; text-transform: uppercase; }\n.dv-card { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); padding: var(--s4); }\n.dv-ph {\n  border-radius: var(--radius);\n  background: linear-gradient(135deg, var(--accent-soft), var(--accent-mid) 60%, var(--accent));\n  aspect-ratio: 16 / 9;\n}\n.dv-ph--alt { background: linear-gradient(200deg, var(--surface), var(--accent-soft) 55%, var(--accent-mid)); }\n.dv-ph--ink { background: linear-gradient(160deg, var(--ink), color-mix(in srgb, var(--ink) 55%, var(--accent))); }\n.dv-end {\n  min-height: 60vh;\n  display: grid;\n  place-items: center;\n  border-top: 1px solid var(--rule);\n  color: var(--muted);\n  font-family: var(--mono);\n  font-size: var(--t-label);\n  letter-spacing: .12em;\n}\n/* dv-motion-base:end */\n\n/* ---- この演出だけの CSS。色は var(--…) か color-mix() のみ ---- */\n.stack { display: grid; gap: var(--s6); }\n.ll-zone { height: 340vh; }\n.ll-stick { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; gap: var(--s4); overflow: clip; }\n@media (prefers-reduced-motion: reduce) {\n  .ll-zone { height: auto; }\n  .ll-stick { position: static; height: auto; padding-block: var(--s6); }\n}\n.ll-svg { display: block; width: 100vw; height: auto; }\n.ll-text { font-family: var(--font); font-size: 150px; font-weight: 700; fill: var(--ink); text-anchor: middle; }\n.ll-link { fill: none; stroke: var(--accent-mid); stroke-width: 26; stroke-linecap: round; stroke-linejoin: round; }\n</style>\n</head>\n<body data-motion-number=\"60\">\n<header class=\"dv-label\">\n  <div class=\"dv-wrap\">\n    <p class=\"dv-num\">60</p>\n    <h1 class=\"dv-name\">文字を繋げる</h1>\n    <p class=\"dv-ai\">Connected letters</p>\n    <p class=\"dv-desc\">文字と文字のあいだを一本の線がつなぐ</p>\n    <p class=\"dv-hint\">SCROLL ↓</p>\n  </div>\n</header>\n\n<main class=\"dv-stage\">\n<div class=\"dv-wrap stack\">\n    <section class=\"dv-card\">\n      <p class=\"dv-eyebrow\">Section 1</p>\n      <h2 class=\"dv-h2\">見出しが入ります</h2>\n      <p class=\"dv-lead\">本文のダミーです。スクロールすると、下の文字のあいだを一本の線が通り、文字と文字をつないでいきます。</p>\n    </section>\n  </div>\n\n  <section class=\"ll-zone\" data-progress=\"pin\">\n    <div class=\"ll-stick\">\n      <svg class=\"ll-svg\" viewBox=\"0 0 1200 300\" role=\"img\" aria-label=\"文字を繋げる\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path class=\"ll-link\" id=\"ll-link\" pathLength=\"1\" d=\"\" />\n        <text class=\"ll-text\" x=\"600\" y=\"200\" aria-hidden=\"true\">文字を繋げる</text>\n      </svg>\n    </div>\n  </section>\n\n  <div class=\"dv-wrap stack\">\n    <section class=\"dv-card\">\n      <p class=\"dv-eyebrow\">Section 2</p>\n      <h2 class=\"dv-h2\">見出しが入ります</h2>\n      <p class=\"dv-lead\">本文のダミーです。つないだ線は、そのまま残ります。</p>\n    </section>\n  </div>\n</main>\n\n<footer class=\"dv-end\">END — 60 文字を繋げる</footer>\n\n<script>\n/* dv-motion-helpers:start */\nconst dvReduce = matchMedia('(prefers-reduced-motion: reduce)').matches;\ndocument.documentElement.classList.add('js');\n\n/**\n * 画面に入った要素へ .is-in を付ける。\n * data-reveal=\"repeat\" の要素は画面を出たら外す（何度でも再生）。\n */\nfunction dvReveal(selector = '[data-reveal]', { threshold = 0.2, rootMargin = '0px 0px -8% 0px' } = {}) {\n  const els = [...document.querySelectorAll(selector)];\n  if (dvReduce || !('IntersectionObserver' in window)) {\n    els.forEach((el) => el.classList.add('is-in'));\n    return;\n  }\n  const io = new IntersectionObserver((entries) => {\n    for (const e of entries) {\n      if (e.isIntersecting) {\n        e.target.classList.add('is-in');\n        if (e.target.dataset.reveal !== 'repeat') io.unobserve(e.target);\n      } else if (e.target.dataset.reveal === 'repeat') {\n        e.target.classList.remove('is-in');\n      }\n    }\n  }, { threshold, rootMargin });\n  els.forEach((el) => io.observe(el));\n}\n\n/**\n * スクロール量を 0〜1 にして、各 [data-progress] 要素の CSS 変数 --p に書く。\n *   data-progress=\"through\" : 要素の上端が画面の下端に来たとき 0、下端が画面の上端を抜けたとき 1\n *   data-progress=\"pin\"     : 要素の上端が画面の上端に来たとき 0、下端が画面の下端に来たとき 1\n *                             （中に position: sticky の箱を持つ、背の高い区画用）\n *   data-progress=\"page\"    : ページ全体のスクロール量\n * onUpdate(el, p) を渡すと、値が変わるたびに呼ばれる。\n */\nfunction dvProgress(onUpdate) {\n  const els = [...document.querySelectorAll('[data-progress]')];\n  const clamp = (n) => Math.min(1, Math.max(0, n));\n  let queued = false;\n  const measure = () => {\n    queued = false;\n    const vh = window.innerHeight;\n    for (const el of els) {\n      const r = el.getBoundingClientRect();\n      let p;\n      if (el.dataset.progress === 'pin') p = r.height > vh ? -r.top / (r.height - vh) : 0;\n      else if (el.dataset.progress === 'page') {\n        const max = document.documentElement.scrollHeight - vh;\n        p = max > 0 ? window.scrollY / max : 0;\n      } else p = (vh - r.top) / (vh + r.height);\n      p = clamp(p);\n      el.style.setProperty('--p', p.toFixed(4));\n      if (onUpdate) onUpdate(el, p);\n    }\n  };\n  const request = () => {\n    if (!queued) { queued = true; requestAnimationFrame(measure); }\n  };\n  addEventListener('scroll', request, { passive: true });\n  addEventListener('resize', request);\n  measure();\n}\n/* dv-motion-helpers:end */\n\n/* ---- この演出だけの JS ---- */\nconst textEl = document.querySelector('.ll-text');\nconst link = document.getElementById('ll-link');\nconst N = [...textEl.textContent].length;\n\n// 各字の外接の中心を拾い、上下に振れる 1 本の曲線で結ぶ。文字の後ろ（SVG の先頭）に描く。\nfunction build() {\n  const pts = [];\n  for (let i = 0; i < N; i++) {\n    const r = textEl.getExtentOfChar(i);\n    const x = r.x + r.width / 2;\n    const y = r.y + r.height * (i % 2 === 0 ? 0.62 : 0.32);\n    pts.push([x, y]);\n  }\n  // 端は少し外へ延ばす。\n  const first = pts[0], last = pts[pts.length - 1];\n  const all = [[first[0] - 90, first[1] + 40], ...pts, [last[0] + 90, last[1] - 40]];\n  // 隣り合う点を、水平な接線の 3 次ベジェでつなぐ。\n  let d = `M${all[0][0].toFixed(1)} ${all[0][1].toFixed(1)}`;\n  for (let i = 1; i < all.length; i++) {\n    const [x0, y0] = all[i - 1], [x1, y1] = all[i], mx = (x0 + x1) / 2;\n    d += ` C${mx.toFixed(1)} ${y0.toFixed(1)} ${mx.toFixed(1)} ${y1.toFixed(1)} ${x1.toFixed(1)} ${y1.toFixed(1)}`;\n  }\n  link.setAttribute('d', d);\n}\nconst ease = (t) => 1 - Math.pow(1 - t, 2);\nfunction place(p) {\n  const t = ease(Math.min(1, Math.max(0, (p - 0.1) / 0.7)));\n  link.style.strokeDasharray = '1';\n  link.style.strokeDashoffset = String(1 - t);\n  link.style.visibility = t > 0 ? 'visible' : 'hidden';\n}\n(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(() => {\n  build();\n  if (!dvReduce) dvProgress((el, p) => place(p));\n});\n</script>\n</body>\n</html>\n"
    }
  },
  "license": "Original",
  "catalogVersion": 1,
  "designSchemaVersion": 2,
  "capabilities": [
    "live-preview",
    "copy-prompt",
    "get-code"
  ],
  "reusePolicy": "construction-reuse",
  "urls": {
    "ja": "https://design-vault.pages.dev/designs/motion-linked-letters",
    "en": "https://design-vault.pages.dev/en/designs/motion-linked-letters",
    "json": "https://design-vault.pages.dev/ai/assets/motion-linked-letters.json",
    "designJson": null
  }
}