スクロールで一語ずつ着色

段落をword単位に分割し、内部スクロールの進行に合わせて薄い文字色から明るいブランド色へ一語ずつ変化させる読み進めハイライト。IntersectionObserverで各wordの着色を制御します。記事やストーリー系のリードに最適です。

#typography#scroll#text#reveal

ライブデモ

使用例(お題: カフェ MOON BREW)

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

HTML
<div class="page">
  <header class="nav">
    <div class="brand"><span class="cup"></span>MOON BREW</div>
    <span class="tag">STORY</span>
  </header>

  <div class="scroller">
    <article class="story">
      <p class="eyebrow">私たちのこと</p>
      <p class="reading" data-text="小さな焙煎機から、すべては始まりました。豆を選び、火と向き合い、香りが立つ瞬間を待つ。私たちが大切にしているのは、急がないこと。一杯のために費やす時間こそが、深い味わいを生むと信じています。今日もまた、月の光の下で、あなたのための一杯を淹れています。">小さな焙煎機から、すべては始まりました。</p>
      <p class="sign">― MOON BREW 焙煎士より</p>
    </article>
  </div>
  <p class="hint">↓ スクロールで読み進める</p>
</div>
CSS
/* MOON BREW:スクロールで一語ずつ着色する読み物が主役 */
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: "Hiragino Mincho ProN", "Yu Mincho", serif;
  background: linear-gradient(165deg, #2b1d12 0%, #1d130a 100%);
  color: #f5ede1;
  min-height: 400px;
  overflow: hidden;
}
.page { padding: 16px 26px; height: 400px; display: flex; flex-direction: column; }

.nav { display: flex; align-items: center; justify-content: space-between; flex: 0 0 auto; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 16px; letter-spacing: 0.06em; }
.cup { width: 16px; height: 16px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #f3d9a8, #c98a3b); }
.tag { font-size: 11px; letter-spacing: 0.3em; color: #c98a3b; font-family: "Segoe UI", sans-serif; }

/* 内部スクロール領域 */
.scroller {
  flex: 1 1 auto;
  margin-top: 12px;
  overflow-y: auto;
  border-radius: 12px;
  padding: 0 4px;
  /* 上下にフェードを掛けて読書ラインを意識させる */
  -webkit-mask-image: linear-gradient(180deg, transparent, #000 18%, #000 78%, transparent);
  mask-image: linear-gradient(180deg, transparent, #000 18%, #000 78%, transparent);
}
.scroller::-webkit-scrollbar { width: 6px; }
.scroller::-webkit-scrollbar-thumb { background: #5a3d22; border-radius: 4px; }

.story { padding: 120px 6px; max-width: 460px; }
.eyebrow {
  font-size: 11px; letter-spacing: 0.26em; color: #c98a3b; font-weight: 600;
  font-family: "Segoe UI", sans-serif; margin-bottom: 14px;
}

/* 読み進めハイライト本体 */
.reading {
  font-size: 19px; line-height: 2.1; letter-spacing: 0.04em;
}
.reading .word {
  /* 既定は薄い文字色(未読) */
  color: #5e4a35;
  transition: color 0.4s ease;
}
.reading .word.lit {
  /* 読書ラインを越えると琥珀ブランド色へ */
  color: #f0d9b0;
}
.sign {
  margin-top: 22px; font-size: 13px; color: #c98a3b; letter-spacing: 0.1em;
}

.hint {
  flex: 0 0 auto;
  text-align: center; font-size: 11px; color: #7a5c3a; margin-top: 8px;
  font-family: "Segoe UI", sans-serif;
}
JavaScript
// スクロール進行に合わせて本文を一語ずつ着色する
(function () {
  const scroller = document.querySelector('.scroller');
  const para = document.querySelector('.reading');
  if (!scroller || !para) return; // null安全

  const text = para.dataset.text || para.textContent || '';
  // 日本語向けに語+区切りで分割
  const tokens = text.match(/[^、。\s]+[、。]?|[、。]/g) || [];

  // 各語をspan化
  para.innerHTML = '';
  tokens.forEach((tok) => {
    const span = document.createElement('span');
    span.className = 'word';
    span.textContent = tok;
    para.appendChild(span);
    para.appendChild(document.createTextNode('​')); // 折り返し許可
  });

  const words = [...para.querySelectorAll('.word')];

  if ('IntersectionObserver' in window) {
    // 読書ライン(上寄り)を越えた語を点灯
    const io = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        const w = entry.target;
        if (entry.isIntersecting) {
          w.classList.add('lit');
        } else if (entry.boundingClientRect.top > 0) {
          w.classList.remove('lit'); // まだ下=未読
        }
      });
    }, { root: scroller, rootMargin: '-42% 0px -50% 0px', threshold: 0 });
    words.forEach((w) => io.observe(w));
  } else {
    // フォールバック:スクロール比率で先頭から点灯
    const apply = () => {
      const max = scroller.scrollHeight - scroller.clientHeight;
      const ratio = max > 0 ? scroller.scrollTop / max : 1;
      const count = Math.round(ratio * words.length);
      words.forEach((w, i) => w.classList.toggle('lit', i < count));
    };
    scroller.addEventListener('scroll', apply, { passive: true });
    apply();
  }
})();

コード

HTML
<main class="stage">
  <p class="kicker">SCROLL TO HIGHLIGHT</p>
  <!-- scroller の中をスクロールすると一語ずつ着色される -->
  <div class="scroller" tabindex="0">
    <p class="reading" data-text="読み進めるほど、言葉がひとつずつ明るく灯っていきます。スクロールの進行に合わせて、薄いグレーの文字が鮮やかなブランドカラーへと一語ずつ変化し、いま読んでいる場所を自然に示します。長い文章でも視線の現在地が分かりやすく、ストーリーや記事のリード文に温度を与える演出です。"></p>
  </div>
  <p class="hint">↑ 枠内をスクロール</p>
</main>
CSS
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  /* CSS変数:色とハイライトの強さ */
  --dim: #4a4d63;          /* 未読の薄い文字色 */
  --brand: #6ee7ff;        /* 読了したブランド色 */
  --brand-glow: rgba(110, 231, 255, 0.55);
}

body {
  min-height: 360px;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, "Hiragino Kaku Gothic ProN", sans-serif;
  background:
    radial-gradient(820px 360px at 85% -10%, #1c2a4e 0%, transparent 55%),
    radial-gradient(820px 360px at 0% 110%, #142b3a 0%, transparent 55%),
    #0b0d16;
  color: #f5f7ff;
  overflow: hidden;
}

.stage { width: min(560px, 92vw); padding: 22px; text-align: center; }

.kicker {
  font-size: 11px;
  letter-spacing: 0.42em;
  color: #7f86b8;
  margin-bottom: 14px;
  padding-left: 0.42em;
}

/* 内部スクロール領域。ここの進行でハイライトする */
.scroller {
  height: 188px;
  overflow: auto;
  text-align: left;
  padding: 16px 20px;
  border-radius: 14px;
  background: rgba(255, 255, 255, 0.035);
  border: 1px solid rgba(255, 255, 255, 0.09);
  scroll-behavior: smooth;
  /* 上下に読みやすいマージンを確保 */
  scrollbar-width: thin;
  scrollbar-color: rgba(110, 231, 255, 0.4) transparent;
}
.scroller::-webkit-scrollbar { width: 8px; }
.scroller::-webkit-scrollbar-thumb {
  background: rgba(110, 231, 255, 0.35);
  border-radius: 999px;
}
.scroller:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

.reading {
  font-size: 19px;
  line-height: 2.1;
  font-weight: 600;
  letter-spacing: 0.01em;
  /* 文末に余白を足して最後の語まで着色できるようにする */
  padding-bottom: 64px;
}

/* JSが生成する各word。初期は薄色 */
.reading .word {
  display: inline-block;
  color: var(--dim);
  transition: color 0.45s ease, text-shadow 0.45s ease;
}
/* 着色済みの語:ブランド色+ほのかな発光 */
.reading .word.lit {
  color: var(--brand);
  text-shadow: 0 0 14px var(--brand-glow);
}

.hint {
  margin-top: 14px;
  font-size: 11px;
  letter-spacing: 0.18em;
  color: #6c739c;
}

/* モーション控えめ設定:トランジションを無効化 */
@media (prefers-reduced-motion: reduce) {
  .scroller { scroll-behavior: auto; }
  .reading .word { transition: none; }
}
JavaScript
// スクロール進行に合わせて段落を一語ずつ着色する
(function () {
  const scroller = document.querySelector('.scroller');
  const para = document.querySelector('.reading');
  if (!scroller || !para) return; // null安全

  const text = para.dataset.text || para.textContent || '';

  // 文章をword(日本語向けに語+区切り)へ分割
  // 句読点・スペースで区切りつつ、区切り文字も語に含めて自然な間隔にする
  const tokens = text.match(/[^、。\s]+[、。]?|[、。]/g) || [];

  // 各wordを<span>化して描画
  function build() {
    para.innerHTML = '';
    tokens.forEach((tok) => {
      const span = document.createElement('span');
      span.className = 'word';
      span.textContent = tok;
      para.appendChild(span);
      // 単語間にゼロ幅の折り返し許可を入れる
      para.appendChild(document.createTextNode('​'));
    });
  }
  build();

  const words = [...para.querySelectorAll('.word')];

  // IntersectionObserver:スクロール領域内の中央より上に来た語を着色
  const supportsIO = 'IntersectionObserver' in window;

  if (supportsIO) {
    // root=scroller。上端から55%の横ラインを読書ラインとみなす
    const io = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        const w = entry.target;
        // 読書ラインを超えて上側に達したら点灯、下に戻れば消灯
        if (entry.isIntersecting) {
          w.classList.add('lit');
        } else if (entry.boundingClientRect.top > 0) {
          // まだ下にある(未読)の場合は消灯
          w.classList.remove('lit');
        }
      });
    }, {
      root: scroller,
      // 下側を大きく削り、上寄り(読み進めた位置)で交差判定させる
      rootMargin: '-45% 0px -50% 0px',
      threshold: 0,
    });
    words.forEach((w) => io.observe(w));
  } else {
    // フォールバック:スクロール量に応じて先頭から比率で点灯
    const apply = () => {
      const max = scroller.scrollHeight - scroller.clientHeight;
      const ratio = max > 0 ? scroller.scrollTop / max : 1;
      const count = Math.round(ratio * words.length);
      words.forEach((w, i) => w.classList.toggle('lit', i < count));
    };
    scroller.addEventListener('scroll', apply, { passive: true });
    apply();
  }
})();

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

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

# 追加してほしい効果
スクロールで一語ずつ着色(タイポグラフィ)
段落をword単位に分割し、内部スクロールの進行に合わせて薄い文字色から明るいブランド色へ一語ずつ変化させる読み進めハイライト。IntersectionObserverで各wordの着色を制御します。記事やストーリー系のリードに最適です。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<main class="stage">
  <p class="kicker">SCROLL TO HIGHLIGHT</p>
  <!-- scroller の中をスクロールすると一語ずつ着色される -->
  <div class="scroller" tabindex="0">
    <p class="reading" data-text="読み進めるほど、言葉がひとつずつ明るく灯っていきます。スクロールの進行に合わせて、薄いグレーの文字が鮮やかなブランドカラーへと一語ずつ変化し、いま読んでいる場所を自然に示します。長い文章でも視線の現在地が分かりやすく、ストーリーや記事のリード文に温度を与える演出です。"></p>
  </div>
  <p class="hint">↑ 枠内をスクロール</p>
</main>

【CSS】
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  /* CSS変数:色とハイライトの強さ */
  --dim: #4a4d63;          /* 未読の薄い文字色 */
  --brand: #6ee7ff;        /* 読了したブランド色 */
  --brand-glow: rgba(110, 231, 255, 0.55);
}

body {
  min-height: 360px;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", system-ui, "Hiragino Kaku Gothic ProN", sans-serif;
  background:
    radial-gradient(820px 360px at 85% -10%, #1c2a4e 0%, transparent 55%),
    radial-gradient(820px 360px at 0% 110%, #142b3a 0%, transparent 55%),
    #0b0d16;
  color: #f5f7ff;
  overflow: hidden;
}

.stage { width: min(560px, 92vw); padding: 22px; text-align: center; }

.kicker {
  font-size: 11px;
  letter-spacing: 0.42em;
  color: #7f86b8;
  margin-bottom: 14px;
  padding-left: 0.42em;
}

/* 内部スクロール領域。ここの進行でハイライトする */
.scroller {
  height: 188px;
  overflow: auto;
  text-align: left;
  padding: 16px 20px;
  border-radius: 14px;
  background: rgba(255, 255, 255, 0.035);
  border: 1px solid rgba(255, 255, 255, 0.09);
  scroll-behavior: smooth;
  /* 上下に読みやすいマージンを確保 */
  scrollbar-width: thin;
  scrollbar-color: rgba(110, 231, 255, 0.4) transparent;
}
.scroller::-webkit-scrollbar { width: 8px; }
.scroller::-webkit-scrollbar-thumb {
  background: rgba(110, 231, 255, 0.35);
  border-radius: 999px;
}
.scroller:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

.reading {
  font-size: 19px;
  line-height: 2.1;
  font-weight: 600;
  letter-spacing: 0.01em;
  /* 文末に余白を足して最後の語まで着色できるようにする */
  padding-bottom: 64px;
}

/* JSが生成する各word。初期は薄色 */
.reading .word {
  display: inline-block;
  color: var(--dim);
  transition: color 0.45s ease, text-shadow 0.45s ease;
}
/* 着色済みの語:ブランド色+ほのかな発光 */
.reading .word.lit {
  color: var(--brand);
  text-shadow: 0 0 14px var(--brand-glow);
}

.hint {
  margin-top: 14px;
  font-size: 11px;
  letter-spacing: 0.18em;
  color: #6c739c;
}

/* モーション控えめ設定:トランジションを無効化 */
@media (prefers-reduced-motion: reduce) {
  .scroller { scroll-behavior: auto; }
  .reading .word { transition: none; }
}

【JavaScript】
// スクロール進行に合わせて段落を一語ずつ着色する
(function () {
  const scroller = document.querySelector('.scroller');
  const para = document.querySelector('.reading');
  if (!scroller || !para) return; // null安全

  const text = para.dataset.text || para.textContent || '';

  // 文章をword(日本語向けに語+区切り)へ分割
  // 句読点・スペースで区切りつつ、区切り文字も語に含めて自然な間隔にする
  const tokens = text.match(/[^、。\s]+[、。]?|[、。]/g) || [];

  // 各wordを<span>化して描画
  function build() {
    para.innerHTML = '';
    tokens.forEach((tok) => {
      const span = document.createElement('span');
      span.className = 'word';
      span.textContent = tok;
      para.appendChild(span);
      // 単語間にゼロ幅の折り返し許可を入れる
      para.appendChild(document.createTextNode('​'));
    });
  }
  build();

  const words = [...para.querySelectorAll('.word')];

  // IntersectionObserver:スクロール領域内の中央より上に来た語を着色
  const supportsIO = 'IntersectionObserver' in window;

  if (supportsIO) {
    // root=scroller。上端から55%の横ラインを読書ラインとみなす
    const io = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        const w = entry.target;
        // 読書ラインを超えて上側に達したら点灯、下に戻れば消灯
        if (entry.isIntersecting) {
          w.classList.add('lit');
        } else if (entry.boundingClientRect.top > 0) {
          // まだ下にある(未読)の場合は消灯
          w.classList.remove('lit');
        }
      });
    }, {
      root: scroller,
      // 下側を大きく削り、上寄り(読み進めた位置)で交差判定させる
      rootMargin: '-45% 0px -50% 0px',
      threshold: 0,
    });
    words.forEach((w) => io.observe(w));
  } else {
    // フォールバック:スクロール量に応じて先頭から比率で点灯
    const apply = () => {
      const max = scroller.scrollHeight - scroller.clientHeight;
      const ratio = max > 0 ? scroller.scrollTop / max : 1;
      const count = Math.round(ratio * words.length);
      words.forEach((w, i) => w.classList.toggle('lit', i < count));
    };
    scroller.addEventListener('scroll', apply, { passive: true });
    apply();
  }
})();

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

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