{
  "id": "motion:motion-text-scramble",
  "slug": "motion-text-scramble",
  "kind": "motion",
  "title": {
    "ja": "テキストスクランブル",
    "en": "Text scramble"
  },
  "aiName": "Text scramble / decode effect",
  "summary": {
    "ja": "乱れた文字が読める文に定まる",
    "en": "Scrambled glyphs settle into readable text"
  },
  "number": 25,
  "group": "text-motion",
  "techniques": [
    "intersection-observer",
    "css-transition"
  ],
  "rules": [
    {
      "ja": "文字は左から順に確定し、一度確定した文字は二度と乱さない",
      "en": "Characters settle from left to right, and a settled character is never scrambled again"
    },
    {
      "ja": "各文字の幅を元の文字の幅に固定し、記号の切り替えで行の幅や折り返しを変えない",
      "en": "Lock each character to the width of its real glyph so swapping never changes the line's width or wrapping"
    },
    {
      "ja": "元の文は HTML と aria-label に残し、乱れた記号は読み上げさせない",
      "en": "Keep the real text in the HTML and in aria-label, and hide the scrambled glyphs from screen readers"
    },
    {
      "ja": "動きを減らす設定では乱さず、最初から読める文を表示する",
      "en": "With reduced motion, never scramble; show the readable text from the start"
    }
  ],
  "reproduction": {
    "prompt": {
      "ja": "テキストスクランブル — Text scramble / decode effect\n\n乱れた記号の列が、画面に入ると左から順に読める文へ定まる演出を作ってください。見出し（最大 72px、太字）と本文（20px、薄い色）を交互に 5 行並べ、どの行も最初は全部の文字をランダムな記号（# % & * + = ? ◆ ■ など 20 種）に置き換えておきます。行が画面の下から 30% の線を越えたら解読を始め、1.2 秒で終えます。最初の 20% の時間は全体が乱れたままで、残りの時間で左の文字から等間隔に 1 字ずつ元の文字に確定し、まだの文字は 50 ミリ秒ごとに別の記号へ切り替えます。乱れている文字はアクセント色を薄めた色、確定した文字は本来の文字色（0.2 秒で変化）にし、各文字の幅は元の文字の幅に固定して行が揺れないようにします。素の JavaScript（requestAnimationFrame と IntersectionObserver）と CSS だけで実装し、ライブラリは使わないでください。\n\n外すと崩れる点:\n- 文字は左から順に確定し、一度確定した文字は二度と乱さない\n- 各文字の幅を元の文字の幅に固定し、記号の切り替えで行の幅や折り返しを変えない\n- 元の文は HTML と aria-label に残し、乱れた記号は読み上げさせない\n- 動きを減らす設定では乱さず、最初から読める文を表示する\n\n動く実物（1 ファイルで完結する HTML）: https://design-vault.pages.dev/designs/motion-text-scramble/demo.html",
      "en": "Text scramble — Text scramble / decode effect\n\nBuild a text scramble effect where a row of random glyphs settles into readable text, left to right, as it enters the viewport. Lay out five lines alternating between a bold heading (up to 72px) and body text (20px, muted), and start every line with all of its characters replaced by random glyphs drawn from a set of 20 (# % & * + = ? ◆ ■ and so on). A line starts decoding when it crosses a line 30% up from the bottom of the viewport, and finishes in 1.2 seconds. For the first 20% of that time the whole line stays scrambled; over the rest, characters lock to their real value one by one from the left at even intervals, while the ones still waiting switch to another glyph every 50 milliseconds. Scrambled characters use a lightened accent colour and settled ones take the normal text colour over 0.2 seconds, and each character keeps the width of its real glyph so the line never shifts. Implement it with plain JavaScript (requestAnimationFrame and IntersectionObserver) and CSS only, with no library.\n\nDo not drop:\n- Characters settle from left to right, and a settled character is never scrambled again\n- Lock each character to the width of its real glyph so swapping never changes the line's width or wrapping\n- Keep the real text in the HTML and in aria-label, and hide the scrambled glyphs from screen readers\n- With reduced motion, never scramble; show the readable text from the start\n\nA working example (one self-contained HTML file): https://design-vault.pages.dev/designs/motion-text-scramble/demo.html"
    },
    "code": {
      "url": "https://design-vault.pages.dev/designs/motion-text-scramble/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>25 テキストスクランブル — Text scramble / decode effect</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.beat { min-height: 46vh; display: grid; align-content: center; gap: var(--s3); }\n.sc { overflow-wrap: anywhere; }\n.sc--head { font-size: var(--t-h1); font-weight: 700; line-height: 1.25; }\n.sc--body { font-size: var(--t-lead); line-height: 1.8; color: var(--muted); }\n.sc .ch { display: inline-block; text-align: center; white-space: pre; color: var(--accent-mid); transition: color .2s var(--ease); }\n.sc .ch.is-set { color: inherit; }\n.meter { display: flex; align-items: center; gap: var(--s3); font-family: var(--mono); font-size: var(--t-label); color: var(--muted); letter-spacing: .08em; }\n.meter-bar { flex: 0 1 240px; height: 2px; background: var(--rule); }\n.meter-bar i { display: block; height: 100%; background: var(--accent); transform-origin: left center; }\n.js .meter-bar i { transform: scaleX(var(--k, 0)); }\n.meter-count { font-variant-numeric: tabular-nums; }\n\n@media (prefers-reduced-motion: reduce) {\n  .sc .ch { transition: none; }\n  .js .meter-bar i { transform: none; }\n}\n</style>\n</head>\n<body data-motion-number=\"25\">\n<header class=\"dv-label\">\n  <div class=\"dv-wrap\">\n    <p class=\"dv-num\">25</p>\n    <h1 class=\"dv-name\">テキストスクランブル</h1>\n    <p class=\"dv-ai\">Text scramble / decode effect</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  <section class=\"dv-wrap beat\">\n    <p class=\"dv-eyebrow\">Line 1</p>\n    <h2 class=\"sc sc--head\" data-scramble>見出しが入ります</h2>\n    <div class=\"meter\" aria-hidden=\"true\"><span class=\"meter-bar\"><i></i></span><span class=\"meter-count\"></span></div>\n  </section>\n  <section class=\"dv-wrap beat\">\n    <p class=\"dv-eyebrow\">Line 2</p>\n    <p class=\"sc sc--body\" data-scramble>本文のダミーです。画面に入ると、乱れた記号が左から順に読める文へ定まります。</p>\n    <div class=\"meter\" aria-hidden=\"true\"><span class=\"meter-bar\"><i></i></span><span class=\"meter-count\"></span></div>\n  </section>\n  <section class=\"dv-wrap beat\">\n    <p class=\"dv-eyebrow\">Line 3</p>\n    <h2 class=\"sc sc--head\" data-scramble>記号から文へ、左から順に</h2>\n    <div class=\"meter\" aria-hidden=\"true\"><span class=\"meter-bar\"><i></i></span><span class=\"meter-count\"></span></div>\n  </section>\n  <section class=\"dv-wrap beat\">\n    <p class=\"dv-eyebrow\">Line 4</p>\n    <p class=\"sc sc--body\" data-scramble>二つ目の本文のダミーです。一度定まった文字は、そのあと動きません。</p>\n    <div class=\"meter\" aria-hidden=\"true\"><span class=\"meter-bar\"><i></i></span><span class=\"meter-count\"></span></div>\n  </section>\n  <section class=\"dv-wrap beat\">\n    <p class=\"dv-eyebrow\">Line 5</p>\n    <h2 class=\"sc sc--head\" data-scramble>最後の一行も同じです</h2>\n    <div class=\"meter\" aria-hidden=\"true\"><span class=\"meter-bar\"><i></i></span><span class=\"meter-count\"></span></div>\n  </section>\n</main>\n\n<footer class=\"dv-end\">END — 25 テキストスクランブル</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 GLYPHS = ['#', '%', '&', '*', '+', '=', '?', '/', '<', '>', '!', '$', '@', '~', '^', '◆', '■', '▲', '●', '×'];\nconst DURATION = 1200; // 1 行が定まりきるまで\nconst SWAP = 50;       // 記号を切り替える間隔\nconst HOLD = 0.2;      // 最初の 1 字が定まるまで、全体が乱れたままの割合\nconst SLIM = 15;       // 先頭の 15 個は幅の狭い記号。句読点のような狭い字にはこちらだけを使う\nconst glyph = (slim) => GLYPHS[Math.floor(Math.random() * (slim ? SLIM : GLYPHS.length))];\n\nconst lines = [...document.querySelectorAll('[data-scramble]')].map((el) => {\n  const meter = el.parentElement.querySelector('.meter');\n  const text = el.textContent.trim();\n  const total = Array.from(text).filter((ch) => !/\\s/.test(ch)).length;\n  return { el, text, total, bar: meter.querySelector('i'), count: meter.querySelector('.meter-count'), chars: [] };\n});\nconst report = (line, set) => {\n  line.bar.style.setProperty('--k', (set / line.total).toFixed(3));\n  line.count.textContent = `${set} / ${line.total}`;\n};\nlines.forEach((line) => line.el.setAttribute('aria-label', line.text));\n\nif (dvReduce || !('IntersectionObserver' in window)) {\n  lines.forEach((line) => report(line, line.total));\n} else {\n  // 1 字ずつ <span> に割る。空白は割らない。\n  for (const line of lines) {\n    line.el.textContent = '';\n    for (const ch of Array.from(line.text)) {\n      if (/\\s/.test(ch)) { line.el.appendChild(document.createTextNode(ch)); continue; }\n      const span = document.createElement('span');\n      span.className = 'ch';\n      span.setAttribute('aria-hidden', 'true');\n      span.textContent = ch;\n      line.el.appendChild(span);\n      line.chars.push({ span, ch });\n    }\n  }\n  // 記号は元の字と幅が違う。元の字の幅を em で固定して、切り替えのたびに行が揺れないようにする。\n  for (const line of lines) {\n    const size = parseFloat(getComputedStyle(line.el).fontSize);\n    line.widths = line.chars.map(({ span }) => span.getBoundingClientRect().width / size);\n  }\n  for (const line of lines) {\n    line.chars.forEach(({ span }, i) => {\n      span.style.width = `${line.widths[i].toFixed(3)}em`;\n      span.textContent = glyph(line.chars[i].slim = line.widths[i] < 0.8);\n    });\n    report(line, 0);\n  }\n\n  const decode = (line) => {\n    const n = line.chars.length;\n    const start = performance.now();\n    let set = 0;\n    let lastTick = -1;\n    const frame = (now) => {\n      const t = now - start;\n      const due = t >= DURATION ? n : Math.max(0, Math.floor(((t / DURATION - HOLD) / (1 - HOLD)) * n));\n      for (; set < due; set++) {\n        line.chars[set].span.textContent = line.chars[set].ch;\n        line.chars[set].span.classList.add('is-set');\n      }\n      const tick = Math.floor(t / SWAP);\n      if (tick !== lastTick) {\n        lastTick = tick;\n        for (let i = set; i < n; i++) line.chars[i].span.textContent = glyph(line.chars[i].slim);\n      }\n      report(line, set);\n      if (set < n) requestAnimationFrame(frame);\n    };\n    requestAnimationFrame(frame);\n  };\n\n  // 画面の下から 30% の線を越えた行から定める。上側は広く取り、飛び越した行も拾う。\n  const byEl = new Map(lines.map((line) => [line.el, line]));\n  const io = new IntersectionObserver((entries) => {\n    for (const e of entries) {\n      if (!e.isIntersecting) continue;\n      io.unobserve(e.target);\n      decode(byEl.get(e.target));\n    }\n  }, { threshold: 0.9, rootMargin: '100000px 0px -30% 0px' });\n  lines.forEach((line) => io.observe(line.el));\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-scramble",
    "en": "https://design-vault.pages.dev/en/designs/motion-text-scramble",
    "json": "https://design-vault.pages.dev/ai/assets/motion-text-scramble.json",
    "designJson": null
  }
}