{
  "id": "motion:motion-text-shatter",
  "slug": "motion-text-shatter",
  "kind": "motion",
  "title": {
    "ja": "粒子ばらけ",
    "en": "Text shatter"
  },
  "aiName": "Text disintegration into particles",
  "summary": {
    "ja": "文字が粒になって風に散っていく",
    "en": "A line of text breaks into particles and is blown away by the wind"
  },
  "number": 46,
  "group": "text-motion",
  "techniques": [
    "canvas",
    "scroll-progress"
  ],
  "rules": [
    {
      "ja": "粒の位置は進みだけで決まる関数にする。時間で積分すると、戻したときに文字へ戻らない",
      "en": "Make particle positions a pure function of progress; integrating over time cannot return to the text on scroll-back"
    },
    {
      "ja": "乱数は固定の種から作り、毎回同じ粒の並びにする",
      "en": "Seed the random generator so the particles are the same every time"
    },
    {
      "ja": "書体が読み込まれてから画素を拾う。先に拾うと別の書体の形を粒にする",
      "en": "Sample pixels after the webfont has loaded, or you will shatter the wrong glyph shapes"
    },
    {
      "ja": "本物の文は読み上げ用に残し、動きを減らす設定では文字のまま止める",
      "en": "Keep the real text for screen readers, and stop on intact text with reduced motion"
    }
  ],
  "reproduction": {
    "prompt": {
      "ja": "粒子ばらけ — Text disintegration into particles\n\n一行の見出しを、スクロールに合わせて粒に砕いて風に散らしてください。見出しを画面外の canvas に太字で描いて画素を拾い、2px 刻み（粒が 9,000 を超えるなら刻みを広げる）で粒の位置を決めます。400vh の pin 区画で、進み 12% までは文字のまま、そこから 90% までに左の粒から順に風が当たります（粒の遅れは x 座標 × 0.45）。1 粒ごとに、右上へ流れる量（canvas 幅の 34〜61%）、向きのばらつき、上昇量を持たせ、位置は進みだけで決まる関数にします。粒は easeIn で加速しながら透明になり、大きさも 40% まで縮みます。粒の約 1 割はアクセント色にします。乱数は固定の種から作る疑似乱数を使い、スクロールを戻すと粒が同じ道を帰って文字に戻ります。本物の文は読み上げ用に視覚的に隠して残し、動きを減らす設定では文字のまま止めます。CSS と JavaScript だけで実装し、ライブラリは使わないでください。\n\n外すと崩れる点:\n- 粒の位置は進みだけで決まる関数にする。時間で積分すると、戻したときに文字へ戻らない\n- 乱数は固定の種から作り、毎回同じ粒の並びにする\n- 書体が読み込まれてから画素を拾う。先に拾うと別の書体の形を粒にする\n- 本物の文は読み上げ用に残し、動きを減らす設定では文字のまま止める\n\n動く実物（1 ファイルで完結する HTML）: https://design-vault.pages.dev/designs/motion-text-shatter/demo.html",
      "en": "Text shatter — Text disintegration into particles\n\nBreak a one-line heading into particles and blow them away as the page scrolls. Draw the heading in bold on an off-screen canvas, read its pixels, and place a particle every 2px, widening the step if there would be more than 9,000. In a 400vh pinned section keep the text intact until 12% progress, then let the wind reach particles from left to right until 90% (each particle is delayed by its x position × 0.45). Give each particle its own drift to the upper right (34–61% of the canvas width), scatter angle and lift, and make its position a pure function of progress. Particles accelerate with an ease-in, fade out and shrink to 40% of their size, and about one in ten is accent-coloured. Use a seeded pseudo-random generator so that scrolling back returns every particle along the same path to the text. Keep the real text visually hidden for screen readers, and with reduced motion stop on the intact text. Implement it with CSS and JavaScript only, without any library.\n\nDo not drop:\n- Make particle positions a pure function of progress; integrating over time cannot return to the text on scroll-back\n- Seed the random generator so the particles are the same every time\n- Sample pixels after the webfont has loaded, or you will shatter the wrong glyph shapes\n- Keep the real text for screen readers, and stop on intact text with reduced motion\n\nA working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-text-shatter/demo.html"
    },
    "code": {
      "url": "https://design-vault.pages.dev/designs/motion-text-shatter/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>46 粒子ばらけ — Text disintegration into particles</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=\"46\">\n<header class=\"dv-label\">\n  <div class=\"dv-wrap\">\n    <p class=\"dv-num\">46</p>\n    <h1 class=\"dv-name\">粒子ばらけ</h1>\n    <p class=\"dv-ai\">Text disintegration into particles</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 — 46 粒子ばらけ</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(7);\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          parts.push({\n            x, y, s: step,\n            a: rnd() * Math.PI * 2,   // 飛ぶ向きのばらつき\n            r: 0.4 + rnd() * 0.9,     // 飛ぶ距離の倍率\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 easeIn = (t) => t * t * (3 - 2 * t) * t;   // 出だしはゆっくり、風に乗ると加速\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  // 最初の 12% は文字のまま見せ、そこから左の粒に風が当たっていく。\n  const e = clamp01((progress - 0.12) / 0.78);\n  for (const q of parts) {\n    const delay = (q.x / W) * 0.45;\n    const t = clamp01((e - delay) / 0.55);\n    if (t <= 0) {\n      ctx.fillStyle = q.k > 0.9 ? accent : ink;\n      ctx.fillRect(q.x, q.y, q.s * 0.9, q.s * 0.9);\n      continue;\n    }\n    const m = easeIn(t);\n    const dx = m * W * (0.22 + q.r * 0.3) + Math.cos(q.a) * m * 60 * q.r;   // 風下（右）へ\n    const dy = -m * H * 0.55 * q.r + Math.sin(q.a) * m * 50 * q.r + m * m * 40;  // 上へ昇り、少し落ちる\n    const alpha = 1 - t * t;\n    if (alpha <= 0.01) continue;\n    const size = q.s * 0.9 * (1 - 0.6 * t);\n    ctx.globalAlpha = alpha;\n    ctx.fillStyle = q.k > 0.9 ? accent : ink;\n    ctx.fillRect(q.x + dx, q.y + dy, size, size);\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 = 0; 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-shatter",
    "en": "https://design-vault.pages.dev/en/designs/motion-text-shatter",
    "json": "https://design-vault.pages.dev/ai/assets/motion-text-shatter.json",
    "designJson": null
  }
}