{
  "id": "motion:motion-text-on-path",
  "slug": "motion-text-on-path",
  "kind": "motion",
  "title": {
    "ja": "曲線に沿う文字",
    "en": "Text on a path"
  },
  "aiName": "Text on a path",
  "summary": {
    "ja": "文字が曲線に沿って流れていく",
    "en": "A sentence flows along a curved path as you scroll"
  },
  "number": 49,
  "group": "text-motion",
  "techniques": [
    "svg",
    "scroll-progress"
  ],
  "rules": [
    {
      "ja": "startOffset は進みだけで決める。文の頭が終点に来た状態が p = 0",
      "en": "Drive startOffset from progress only; p = 0 is the sentence head at the path end"
    },
    {
      "ja": "文の長さは書体が読み込まれてから測る。先に測るとずれる",
      "en": "Measure the text length after the webfont loads, or the exit point drifts"
    },
    {
      "ja": "道は画面の両端をはみ出させ、文が途中で切れて見えないようにする",
      "en": "Run the path past both screen edges so the sentence is never visibly cut mid-curve"
    },
    {
      "ja": "本物の文は aria-label に残し、動きを減らす設定では全体が道に乗った位置で止める",
      "en": "Keep the real text in aria-label, and rest with the whole sentence on the path under reduced motion"
    }
  ],
  "reproduction": {
    "prompt": {
      "ja": "曲線に沿う文字 — Text on a path\n\n曲がりくねった道に沿って、一文が流れていく動きを作ってください。SVG（viewBox 1200 × 560）に、画面の左右をはみ出す S 字の path を点線で引き、その上に textPath で太字の一文（64px）を乗せます。360vh の pin 区画の進み p に合わせて startOffset を「道の全長 − p × (道の全長 + 文の長さ)」にし、文の頭が道の終点に現れてから、文の最後が始点を抜けるまで流します。文中の一語だけアクセント色にします。文の長さは書体で変わるので、書体が読み込まれてから getComputedTextLength で測ります。本物の文は SVG の aria-label に残し、動きを減らす設定では文全体が道に乗った位置で止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。\n\n外すと崩れる点:\n- startOffset は進みだけで決める。文の頭が終点に来た状態が p = 0\n- 文の長さは書体が読み込まれてから測る。先に測るとずれる\n- 道は画面の両端をはみ出させ、文が途中で切れて見えないようにする\n- 本物の文は aria-label に残し、動きを減らす設定では全体が道に乗った位置で止める\n\n動く実物（1 ファイルで完結する HTML）: https://design-vault.pages.dev/designs/motion-text-on-path/demo.html",
      "en": "Text on a path — Text on a path\n\nMake 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.\n\nDo not drop:\n- Drive startOffset from progress only; p = 0 is the sentence head at the path end\n- Measure the text length after the webfont loads, or the exit point drifts\n- Run the path past both screen edges so the sentence is never visibly cut mid-curve\n- Keep the real text in aria-label, and rest with the whole sentence on the path under reduced motion\n\nA working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-text-on-path/demo.html"
    },
    "code": {
      "url": "https://design-vault.pages.dev/designs/motion-text-on-path/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>49 曲線に沿う文字 — Text on a path</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.tp-zone { height: 360vh; }\n.tp-stick { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; }\n/* 幅は画面から決める。 */\n.tp-svg { display: block; width: 100vw; height: auto; max-height: 90vh; }\n.tp-rail { fill: none; stroke: var(--rule); stroke-width: 2; stroke-dasharray: 2 10; stroke-linecap: round; }\n.tp-text { font-family: var(--font); font-size: 64px; font-weight: 700; fill: var(--ink); letter-spacing: .04em; }\n.tp-text .tp-em { fill: var(--accent); }\n\n@media (prefers-reduced-motion: reduce) {\n  .tp-zone { height: auto; }\n  .tp-stick { position: static; height: auto; padding-block: var(--s6); }\n}\n</style>\n</head>\n<body data-motion-number=\"49\">\n<header class=\"dv-label\">\n  <div class=\"dv-wrap\">\n    <p class=\"dv-num\">49</p>\n    <h1 class=\"dv-name\">曲線に沿う文字</h1>\n    <p class=\"dv-ai\">Text on a path</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=\"tp-zone\" data-progress=\"pin\">\n    <div class=\"tp-stick\">\n      <svg class=\"tp-svg\" viewBox=\"0 0 1200 560\" role=\"img\" aria-label=\"言葉は曲がりくねった道をたどって、遠くへ届く。\" xmlns=\"http://www.w3.org/2000/svg\">\n        <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\"/>\n        <text class=\"tp-text\" aria-hidden=\"true\"><textPath href=\"#tp-rail\" startOffset=\"8%\">言葉は曲がりくねった道をたどって、<tspan class=\"tp-em\">遠くへ</tspan>届く。</textPath></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 — 49 曲線に沿う文字</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 tp = document.querySelector('.tp-text textPath');\nconst rail = document.getElementById('tp-rail');\nconst textEl = document.querySelector('.tp-text');\nconst railLen = rail.getTotalLength();\nlet textLen = textEl.getComputedTextLength();\n\n// 文の頭が道の終点にあるところから、文の最後が道の始点を抜けるまで。\nfunction place(p) {\n  tp.setAttribute('startOffset', (railLen - p * (railLen + textLen)).toFixed(1));\n}\n// 文の長さは書体で変わるので、書体が読み込まれてから測る。JS が動かないときは、\n// マークアップの startOffset（8%）のまま、文が道に乗って読める。\n(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(() => {\n  textLen = textEl.getComputedTextLength();\n  if (dvReduce) {\n    // 動きを減らす設定では、文全体が道に乗った位置で止める。\n    tp.setAttribute('startOffset', Math.max(0, (railLen - textLen) / 2).toFixed(1));\n  } else {\n    dvProgress((el, p) => place(p));\n  }\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-text-on-path",
    "en": "https://design-vault.pages.dev/en/designs/motion-text-on-path",
    "json": "https://design-vault.pages.dev/ai/assets/motion-text-on-path.json",
    "designJson": null
  }
}