テキストスクランブル

文字をランダムに揺らしながら正しい単語へ収束させるレトロな演出。requestAnimationFrameで実装し、ヒーロー見出しのアイキャッチに最適です。

#js#animation#text#raf

ライブデモ

使用例(お題: SaaS FlowDesk)

この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- FlowDesk: ヒーロー見出しをテキストスクランブルで切替 -->
<div class="fd">
  <header class="fd__top">
    <span class="fd__logo"><span class="fd__mark"></span>FlowDesk</span>
    <span class="fd__login">ログイン</span>
  </header>

  <section class="fd__hero">
    <p class="fd__eyebrow">ALL-IN-ONE WORKSPACE</p>
    <h1 class="fd__title">
      <span class="fd__static">FlowDeskで、</span>
      <span class="scramble" data-words="タスクを,ナレッジを,チームを,生産性を">タスクを</span>
      <span class="fd__static">加速する。</span>
    </h1>
    <p class="fd__lead">散らばった情報を一箇所に。あなたのチームの「次の一手」が、いつも見える。</p>
    <div class="fd__row">
      <button class="scramble__btn" type="button">見出しを切り替える</button>
      <span class="fd__note">14日間 無料トライアル</span>
    </div>
  </section>
</div>
CSS
/* FlowDesk SaaS テーマ: 紺/青/白 */
* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 20% 0%, rgba(79,124,255,.26) 0%, transparent 48%),
    radial-gradient(circle at 100% 100%, rgba(79,124,255,.14) 0%, transparent 42%),
    #0f1b34;
  color: #fff;
}

.fd {
  height: 400px;
  display: flex;
  flex-direction: column;
  padding: 0 34px;
}

.fd__top {
  display: flex;
  align-items: center;
  padding: 18px 0;
}
.fd__logo {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  font-weight: 800;
  font-size: 16px;
}
.fd__mark {
  width: 18px;
  height: 18px;
  border-radius: 6px;
  background: linear-gradient(135deg, #4f7cff, #8fb0ff);
  box-shadow: 0 0 16px rgba(79,124,255,.6);
}
.fd__login {
  margin-left: auto;
  font-size: 13px;
  color: rgba(255,255,255,.82);
  border: 1px solid rgba(255,255,255,.22);
  padding: 7px 16px;
  border-radius: 999px;
}

.fd__hero {
  flex: 1;
  display: grid;
  align-content: center;
  gap: 16px;
}
.fd__eyebrow {
  margin: 0;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .2em;
  color: #7da0ff;
}
.fd__title {
  margin: 0;
  font-size: 34px;
  font-weight: 800;
  line-height: 1.3;
  letter-spacing: .01em;
}
.fd__static { color: #fff; }

/* 主役: スクランブルする語 */
.scramble {
  display: inline-block;
  min-width: 4.2em;
  color: #6f96ff;
  font-family: "Segoe UI", monospace, system-ui;
}
.scramble .dud {
  color: #aebfff;
  opacity: .85;
}

.fd__lead {
  margin: 0;
  font-size: 14px;
  line-height: 1.7;
  color: rgba(255,255,255,.64);
  max-width: 30em;
}

.fd__row {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-top: 6px;
}
.scramble__btn {
  font-size: 13px;
  font-weight: 700;
  color: #fff;
  background: linear-gradient(135deg, #4f7cff, #6f96ff);
  border: none;
  padding: 11px 22px;
  border-radius: 999px;
  cursor: pointer;
  box-shadow: 0 10px 26px rgba(79,124,255,.4);
}
.scramble__btn:hover { filter: brightness(1.07); }
.fd__note {
  font-size: 12px;
  color: rgba(255,255,255,.5);
}
JavaScript
// FlowDesk見出し: 文字をランダムに揺らしながら正しい語へ収束させる
(() => {
  const el = document.querySelector('.scramble');
  const btn = document.querySelector('.scramble__btn');
  if (!el) return; // null安全

  const words = (el.dataset.words || el.textContent || '').split(',').map(s => s.trim()).filter(Boolean);
  if (!words.length) return;

  const CHARS = '!<>-_\\/[]{}—=+*^?#________';
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let index = 0;
  let frame = 0;
  let rafId = null;

  // 旧→新へ文字ごとに時間差で遷移
  const setText = (newText) => {
    const oldText = el.textContent;
    const len = Math.max(oldText.length, newText.length);
    if (reduce) { el.textContent = newText; return; } // 抑制時は即時

    const queue = [];
    for (let i = 0; i < len; i++) {
      const from = oldText[i] || '';
      const to = newText[i] || '';
      const start = Math.floor(Math.random() * 30);
      const end = start + Math.floor(Math.random() * 30);
      queue.push({ from, to, start, end, char: '' });
    }
    frame = 0;
    cancelAnimationFrame(rafId);

    const tick = () => {
      let out = '';
      let done = 0;
      for (const q of queue) {
        if (frame >= q.end) { done++; out += q.to; }
        else if (frame >= q.start) {
          // ランダム文字を時々差し替えてノイズ感を出す
          if (!q.char || Math.random() < 0.28) {
            q.char = CHARS[Math.floor(Math.random() * CHARS.length)];
          }
          out += `<span class="dud">${q.char}</span>`;
        } else out += q.from;
      }
      el.innerHTML = out;
      if (done === queue.length) return;
      frame++;
      rafId = requestAnimationFrame(tick);
    };
    tick();
  };

  // ボタンで次の語へ
  if (btn) btn.addEventListener('click', () => {
    index = (index + 1) % words.length;
    setText(words[index]);
  });

  // 初回演出
  setText(words[0]);
})();

コード

HTML
<!-- テキストスクランブル: 文字が乱れながら正しい単語に収束する -->
<div class="stage">
  <h2 class="scramble" data-words="Design,Motion,Delight,Pixel">Design</h2>
  <button class="scramble__btn" type="button">次の言葉へ</button>
</div>
CSS
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: #07070c;
  color: #dffcf3;
}

.stage {
  display: grid;
  place-items: center;
  gap: 26px;
}

/* スクランブル表示。等幅でガタつきを防ぐ */
.scramble {
  margin: 0;
  font-family: ui-monospace, "Courier New", monospace;
  font-size: clamp(40px, 9vw, 64px);
  font-weight: 700;
  letter-spacing: .06em;
  min-height: 1.2em;
  background: linear-gradient(90deg, #00ffa3, #58c7ff);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

/* 確定前の乱数文字を淡く見せる */
.scramble .dud {
  color: rgba(120, 255, 220, .45);
  -webkit-text-fill-color: rgba(120, 255, 220, .45);
}

.scramble__btn {
  padding: 12px 28px;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: .04em;
  color: #07070c;
  background: linear-gradient(90deg, #00ffa3, #58c7ff);
  border: none;
  border-radius: 999px;
  cursor: pointer;
  transition: transform .12s ease, box-shadow .25s ease;
  box-shadow: 0 8px 22px rgba(0, 255, 163, .25);
}
.scramble__btn:hover { box-shadow: 0 10px 28px rgba(88, 199, 255, .4); }
.scramble__btn:active { transform: scale(.96); }
JavaScript
// テキストスクランブル: 各文字をランダム文字で揺らしつつ、時間差で正解へ収束
(() => {
  const el = document.querySelector('.scramble');
  const btn = document.querySelector('.scramble__btn');
  if (!el) return; // null安全

  const words = (el.dataset.words || el.textContent || '').split(',').map(s => s.trim()).filter(Boolean);
  if (!words.length) return;

  const CHARS = '!<>-_\\/[]{}—=+*^?#________';
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let index = 0;
  let frame = 0;
  let rafId = null;

  // 旧テキスト→新テキストへ文字ごとに遷移するキューを構築
  const setText = (newText) => {
    const oldText = el.textContent;
    const len = Math.max(oldText.length, newText.length);
    return new Promise((resolve) => {
      // モーション抑制時は即時切替
      if (reduce) { el.textContent = newText; resolve(); return; }

      const queue = [];
      for (let i = 0; i < len; i++) {
        const from = oldText[i] || '';
        const to = newText[i] || '';
        const start = Math.floor(Math.random() * 40);
        const end = start + Math.floor(Math.random() * 40);
        queue.push({ from, to, start, end, char: '' });
      }
      frame = 0;
      cancelAnimationFrame(rafId);

      const tick = () => {
        let out = '';
        let done = 0;
        for (const q of queue) {
          if (frame >= q.end) {
            done++;
            out += q.to;
          } else if (frame >= q.start) {
            // ランダム文字を時々差し替えてノイズ感を出す
            if (!q.char || Math.random() < 0.28) {
              q.char = CHARS[Math.floor(Math.random() * CHARS.length)];
            }
            out += `<span class="dud">${q.char}</span>`;
          } else {
            out += q.from;
          }
        }
        el.innerHTML = out;
        if (done === queue.length) { resolve(); return; }
        frame++;
        rafId = requestAnimationFrame(tick);
      };
      tick();
    });
  };

  const next = () => {
    index = (index + 1) % words.length;
    setText(words[index]);
  };

  if (btn) btn.addEventListener('click', next);
  // 初回演出: 現在の語をスクランブルしながら表示
  setText(words[0]);
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「テキストスクランブル」の効果を追加してください。

# 追加してほしい効果
テキストスクランブル(マイクロインタラクション)
文字をランダムに揺らしながら正しい単語へ収束させるレトロな演出。requestAnimationFrameで実装し、ヒーロー見出しのアイキャッチに最適です。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- テキストスクランブル: 文字が乱れながら正しい単語に収束する -->
<div class="stage">
  <h2 class="scramble" data-words="Design,Motion,Delight,Pixel">Design</h2>
  <button class="scramble__btn" type="button">次の言葉へ</button>
</div>

【CSS】
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: #07070c;
  color: #dffcf3;
}

.stage {
  display: grid;
  place-items: center;
  gap: 26px;
}

/* スクランブル表示。等幅でガタつきを防ぐ */
.scramble {
  margin: 0;
  font-family: ui-monospace, "Courier New", monospace;
  font-size: clamp(40px, 9vw, 64px);
  font-weight: 700;
  letter-spacing: .06em;
  min-height: 1.2em;
  background: linear-gradient(90deg, #00ffa3, #58c7ff);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

/* 確定前の乱数文字を淡く見せる */
.scramble .dud {
  color: rgba(120, 255, 220, .45);
  -webkit-text-fill-color: rgba(120, 255, 220, .45);
}

.scramble__btn {
  padding: 12px 28px;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: .04em;
  color: #07070c;
  background: linear-gradient(90deg, #00ffa3, #58c7ff);
  border: none;
  border-radius: 999px;
  cursor: pointer;
  transition: transform .12s ease, box-shadow .25s ease;
  box-shadow: 0 8px 22px rgba(0, 255, 163, .25);
}
.scramble__btn:hover { box-shadow: 0 10px 28px rgba(88, 199, 255, .4); }
.scramble__btn:active { transform: scale(.96); }

【JavaScript】
// テキストスクランブル: 各文字をランダム文字で揺らしつつ、時間差で正解へ収束
(() => {
  const el = document.querySelector('.scramble');
  const btn = document.querySelector('.scramble__btn');
  if (!el) return; // null安全

  const words = (el.dataset.words || el.textContent || '').split(',').map(s => s.trim()).filter(Boolean);
  if (!words.length) return;

  const CHARS = '!<>-_\\/[]{}—=+*^?#________';
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let index = 0;
  let frame = 0;
  let rafId = null;

  // 旧テキスト→新テキストへ文字ごとに遷移するキューを構築
  const setText = (newText) => {
    const oldText = el.textContent;
    const len = Math.max(oldText.length, newText.length);
    return new Promise((resolve) => {
      // モーション抑制時は即時切替
      if (reduce) { el.textContent = newText; resolve(); return; }

      const queue = [];
      for (let i = 0; i < len; i++) {
        const from = oldText[i] || '';
        const to = newText[i] || '';
        const start = Math.floor(Math.random() * 40);
        const end = start + Math.floor(Math.random() * 40);
        queue.push({ from, to, start, end, char: '' });
      }
      frame = 0;
      cancelAnimationFrame(rafId);

      const tick = () => {
        let out = '';
        let done = 0;
        for (const q of queue) {
          if (frame >= q.end) {
            done++;
            out += q.to;
          } else if (frame >= q.start) {
            // ランダム文字を時々差し替えてノイズ感を出す
            if (!q.char || Math.random() < 0.28) {
              q.char = CHARS[Math.floor(Math.random() * CHARS.length)];
            }
            out += `<span class="dud">${q.char}</span>`;
          } else {
            out += q.from;
          }
        }
        el.innerHTML = out;
        if (done === queue.length) { resolve(); return; }
        frame++;
        rafId = requestAnimationFrame(tick);
      };
      tick();
    });
  };

  const next = () => {
    index = (index + 1) % words.length;
    setText(words[index]);
  };

  if (btn) btn.addEventListener('click', next);
  // 初回演出: 現在の語をスクランブルしながら表示
  setText(words[0]);
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。