{
  "id": "motion:motion-text-gather",
  "slug": "motion-text-gather",
  "kind": "motion",
  "title": {
    "ja": "粒子集結",
    "en": "Text gather"
  },
  "aiName": "Particles assemble into text",
  "summary": {
    "ja": "散らばった粒が集まって文字になる",
    "en": "Scattered particles swirl together and settle into a line of text"
  },
  "number": 47,
  "group": "text-motion",
  "techniques": [
    "canvas",
    "scroll-progress"
  ],
  "rules": [
    {
      "ja": "粒の位置は進みだけで決まる関数にする。戻したときに同じ道で散らばる",
      "en": "Make particle positions a pure function of progress so scrolling back scatters them along the same paths"
    },
    {
      "ja": "出発の遅れと渦の向きは粒ごとに固定の乱数で決める。毎回同じ集まり方にする",
      "en": "Fix each particle’s delay and swirl from a seeded random so the assembly is identical every time"
    },
    {
      "ja": "着いた粒は半径を広げて隣と重ね、文字が面として読めるようにする",
      "en": "Grow the radius on arrival so neighbours merge and the letters read as solid shapes"
    },
    {
      "ja": "本物の文は読み上げ用に残し、動きを減らす設定では集まった文字で止める",
      "en": "Keep the real text for screen readers, and stop on the assembled text with reduced motion"
    }
  ],
  "reproduction": {
    "prompt": {
      "ja": "粒子集結 — Particles assemble into text\n\n散らばった粒が渦を巻いて集まり、一行の見出しになる動きを作ってください。見出しを画面外の canvas に太字で描いて画素を拾い、2px 刻み（粒が 9,000 を超えるなら刻みを広げる）で「行き先」を決めます。出発点は画面の中心のまわりの大きな輪（幅の 27〜65%）の上に、粒ごとにばらまきます。400vh の pin 区画で、進み 6% から 70% までに集め、あとは文字のまま残します。1 粒ごとに 0〜0.4 の出発の遅れと渦の向き・強さを持たせ、出発点と行き先を結ぶ線から横へ最大 90px ふくらむ弧を easeOut（1 − (1 − t)³）でたどらせます（着く手前でふくらみは 0 に戻る）。粒は小さな円で、着くにつれて半径が大きくなり隣と重なって文字の面になります。位置は進みだけで決まる関数にして、戻すと文字がまた粒にほどけます。本物の文は読み上げ用に視覚的に隠して残し、動きを減らす設定では集まった文字で止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。\n\n外すと崩れる点:\n- 粒の位置は進みだけで決まる関数にする。戻したときに同じ道で散らばる\n- 出発の遅れと渦の向きは粒ごとに固定の乱数で決める。毎回同じ集まり方にする\n- 着いた粒は半径を広げて隣と重ね、文字が面として読めるようにする\n- 本物の文は読み上げ用に残し、動きを減らす設定では集まった文字で止める\n\n動く実物（1 ファイルで完結する HTML）: https://design-vault.pages.dev/designs/motion-text-gather/demo.html",
      "en": "Text gather — Particles assemble into text\n\nMake scattered particles swirl together and settle into a one-line heading. Draw the heading in bold on an off-screen canvas, read its pixels, and give each particle a destination every 2px, widening the step if there would be more than 9,000. Scatter each particle’s start point on a large ring around the canvas centre (27–65% of its width). In a 400vh pinned section gather them from 6% to 70% progress and then hold the text. Give each particle a start delay of 0–0.4, a swirl direction and strength, and move it along an arc that bulges sideways by up to 90px from the straight line between start and destination, easing out with 1 − (1 − t)³ and returning to zero bulge on arrival. Draw particles as small circles whose radius grows as they arrive so that neighbours merge into solid letters. Make position a pure function of progress so scrolling back unravels the text into particles. Keep the real text visually hidden for screen readers, and with reduced motion stop on the assembled text. Implement it with CSS and JavaScript only, without any library.\n\nDo not drop:\n- Make particle positions a pure function of progress so scrolling back scatters them along the same paths\n- Fix each particle’s delay and swirl from a seeded random so the assembly is identical every time\n- Grow the radius on arrival so neighbours merge and the letters read as solid shapes\n- Keep the real text for screen readers, and stop on the assembled text with reduced motion\n\nA working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-text-gather/demo.html"
    },
    "code": {
      "url": "https://design-vault.pages.dev/designs/motion-text-gather/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>47 粒子集結 — Particles assemble into text</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.pt-zone { height: 400vh; }\n.pt-stick {\n  position: sticky; top: 0; height: 100vh;\n  display: grid; place-content: center; justify-items: center; gap: var(--s4);\n}\n/* 幅は画面から決める。中央寄せの grid の中で % を使うと箱が点まで縮む。 */\n.pt-canvas { display: block; width: 100vw; aspect-ratio: 16 / 7; }\n@media (max-width: 640px) { .pt-canvas { aspect-ratio: 1 / 1; } }\n.pt-text { font-size: var(--t-h2); font-weight: 700; line-height: 1.3; text-align: center; }\n/* JS が動くあいだは文字を粒で描くので、本物の文は読み上げ用に残して見えなくする。 */\n.js .pt-text {\n  position: absolute; width: 1px; height: 1px; overflow: hidden;\n  clip-path: inset(50%); white-space: nowrap;\n}\n.pt-count { font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .12em; }\n\n@media (prefers-reduced-motion: reduce) {\n  .pt-zone { height: auto; }\n  .pt-stick { position: static; height: auto; padding-block: var(--s6); }\n}\n</style>\n</head>\n<body data-motion-number=\"47\">\n<header class=\"dv-label\">\n  <div class=\"dv-wrap\">\n    <p class=\"dv-num\">47</p>\n    <h1 class=\"dv-name\">粒子集結</h1>\n    <p class=\"dv-ai\">Particles assemble into text</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=\"pt-zone\" data-progress=\"pin\">\n    <div class=\"pt-stick\">\n      <h2 class=\"pt-text\">言葉は、粒から生まれる。</h2>\n      <canvas class=\"pt-canvas\" aria-hidden=\"true\"></canvas>\n      <p class=\"pt-count\">粒 0</p>\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 — 47 粒子集結</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 canvas = document.querySelector('.pt-canvas');\nconst countEl = document.querySelector('.pt-count');\nconst text = document.querySelector('.pt-text').textContent.trim();\nconst ctx = canvas.getContext('2d');\nconst role = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n\n// 毎回同じ並びになる乱数。スクロールを戻したとき、粒が同じ道を帰るために使う。\nfunction seeded(seed) {\n  return () => {\n    seed = (seed + 0x6d2b79f5) | 0;\n    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nlet W = 0, H = 0, parts = [], progress = 0, queued = false;\n\n/** 文字を描いて画素を拾い、粒の「行き先」を決める。出発点は行き先ごとに散らす。 */\nfunction sample() {\n  W = canvas.clientWidth; H = canvas.clientHeight;\n  const dpr = Math.min(2, window.devicePixelRatio || 1);\n  canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);\n  const off = document.createElement('canvas');\n  off.width = W; off.height = H;\n  const o = off.getContext('2d', { willReadFrequently: true });\n  o.fillStyle = role('--ink');\n  // 狭い画面では読点で 2 行に割り、文字を大きく保つ。\n  const lines = W < 640 ? text.split(/(?<=、)/) : [text];\n  const longest = Math.max(...lines.map((l) => l.length));\n  const size = Math.round(Math.min((W * 0.86) / (longest + 0.4), H * 0.7 / lines.length));\n  o.font = `700 ${size}px ${getComputedStyle(document.body).fontFamily}`;\n  o.textAlign = 'center'; o.textBaseline = 'middle';\n  lines.forEach((l, i) => o.fillText(l, W / 2, H / 2 + (i - (lines.length - 1) / 2) * size * 1.25));\n  const data = o.getImageData(0, 0, W, H).data;\n  const rnd = seeded(11);\n  for (let step = 2; ; step++) {\n    parts = [];\n    for (let y = 0; y < H; y += step) {\n      for (let x = 0; x < W; x += step) {\n        if (data[(y * W + x) * 4 + 3] > 128) {\n          // 出発点は、画面の中心のまわりの大きな輪の上。\n          const a = rnd() * Math.PI * 2, r = (0.55 + rnd() * 0.75) * W * 0.5;\n          parts.push({\n            x, y, s: step,\n            sx: W / 2 + Math.cos(a) * r,\n            sy: H / 2 + Math.sin(a) * r * 0.7,\n            d: rnd() * 0.4,           // 出発の遅れ\n            w: (rnd() - 0.5) * 2,     // 渦の向きと強さ\n            k: rnd(),\n          });\n        }\n      }\n    }\n    if (parts.length <= 9000 || step > 12) break;\n  }\n  countEl.textContent = `粒 ${parts.length.toLocaleString('en-US')}`;\n}\n\nconst clamp01 = (n) => Math.min(1, Math.max(0, n));\nconst ease = (t) => 1 - Math.pow(1 - t, 3);\n\nfunction render() {\n  queued = false;\n  const dpr = canvas.width / W;\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  ctx.clearRect(0, 0, W, H);\n  const ink = role('--ink'), accent = role('--accent');\n  // 先頭の 6% は散らばったまま、70% までに集まり、あとは文字のまま残る。\n  const e = clamp01((progress - 0.06) / 0.64);\n  for (const q of parts) {\n    const t = clamp01((e - q.d) / 0.6);\n    const m = ease(t);\n    // 出発点と行き先を結ぶ直線から、横へふくらむ弧（渦）。着く手前でゼロに戻る。\n    const bx = q.sx + (q.x - q.sx) * m, by = q.sy + (q.y - q.sy) * m;\n    const bulge = Math.sin(m * Math.PI) * q.w * 90;\n    const nx = -(q.y - q.sy), ny = q.x - q.sx, len = Math.hypot(nx, ny) || 1;\n    ctx.globalAlpha = Math.min(1, 0.25 + t * 3);\n    ctx.fillStyle = q.k > 0.9 && t < 1 ? accent : ink;\n    ctx.beginPath();\n    ctx.arc(bx + (nx / len) * bulge + q.s * 0.45, by + (ny / len) * bulge + q.s * 0.45, q.s * (0.35 + 0.2 * m), 0, Math.PI * 2);\n    ctx.fill();\n  }\n  ctx.globalAlpha = 1;\n}\n\nconst request = () => { if (!queued) { queued = true; requestAnimationFrame(render); } };\n// 画面の大きさが変わったときだけ、間引いて拾い直す。モバイルでは URL バーの出入りでも\n// resize が何度も飛ぶ。\nlet lastSize = '';\nlet resampling = false;\nfunction resample() {\n  const size = canvas.clientWidth + 'x' + canvas.clientHeight;\n  if (size === lastSize || resampling) return;\n  resampling = true;\n  requestAnimationFrame(() => { resampling = false; lastSize = canvas.clientWidth + 'x' + canvas.clientHeight; sample(); request(); });\n}\nfunction start() {\n  sample();\n  lastSize = canvas.clientWidth + 'x' + canvas.clientHeight;\n  addEventListener('resize', resample);\n  if (dvReduce) { progress = 1; request(); return; }   // 動きを減らす設定では、集まった文字で止める\n  dvProgress((el, p) => { progress = p; request(); });\n}\n// 書体が読み込まれる前に画素を拾うと、別の書体の形を粒にしてしまう。\n(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(start);\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-gather",
    "en": "https://design-vault.pages.dev/en/designs/motion-text-gather",
    "json": "https://design-vault.pages.dev/ai/assets/motion-text-gather.json",
    "designJson": null
  }
}