スクロール文章ハイライト

スクロール進捗に合わせて文章を単語ごとに点灯させ、読み手の視線を導きます。引用や物語の見せ場に。

#javascript#typography#animation

ライブデモ

使用例(お題: アイドルグループ Sakura)

この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- Sakura 新曲歌詞ページ。スクロールで歌詞が一語ずつ点灯 -->
<div class="skt-scroller" id="thScroller">
  <header class="skt-head">
    <span class="skt-kicker">NEW SONG / LYRICS</span>
    <h1>はなびらメロディ</h1>
    <p class="skt-credit">作詞 あおい ・ 作曲 Sakura Sound</p>
    <span class="skt-arrow" aria-hidden="true">&#8595;</span>
  </header>

  <section class="skt-stage">
    <!-- スクロール進捗に合わせて単語が点灯する歌詞 -->
    <p class="skt-lyrics" id="thText">
      <span class="th-word">風が</span> <span class="th-word">運ぶ</span> <span class="th-word">春の</span> <span class="th-word">合図</span> <span class="th-word">きみと</span> <span class="th-word">見上げた</span> <span class="th-word">空に</span> <span class="th-word">舞う</span> <span class="th-word">はなびら</span> <span class="th-word">ひとつ</span> <span class="th-word">ふたつ</span> <span class="th-word">数えた</span> <span class="th-word">あの日の</span> <span class="th-word">続きを</span> <span class="th-word">いま</span> <span class="th-word">歌うよ</span> <span class="th-word">ずっと</span> <span class="th-word">忘れない</span> <span class="th-word">この</span> <span class="th-word">メロディ</span>
    </p>
  </section>

  <footer class="skt-foot">
    <p>Sakura 3rd Single「はなびらメロディ」4月リリース。</p>
  </footer>
</div>
CSS
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --pink: #ffd1e0;
  --pink-deep: #ff8fb3;
  --gray: #eef0f3;
  --ink: #5a4853;
}

body {
  font-family: "Hiragino Mincho ProN", "Yu Mincho", "Segoe UI", serif;
  background: #fff;
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
}

/* 自前スクロール領域 */
.skt-scroller {
  width: 100%;
  height: 100vh;
  max-height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
  scrollbar-color: var(--pink-deep) transparent;
  background: linear-gradient(180deg, #fff 0%, #fff5f9 100%);
}

.skt-head {
  height: 220px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  text-align: center;
  background: linear-gradient(180deg, var(--pink), #fff5f9);
}
.skt-kicker { font-family: "Segoe UI", sans-serif; font-size: .62rem; letter-spacing: .28em; color: var(--pink-deep); }
.skt-head h1 { font-size: 2rem; font-weight: 700; letter-spacing: .08em; color: #c4396a; }
.skt-credit { font-size: .76rem; color: #9a7080; }
.skt-arrow { margin-top: 2px; font-size: 1.3rem; color: var(--pink-deep); animation: sktBob 1.6s ease-in-out infinite; }
@keyframes sktBob {
  0%,100% { transform: translateY(0); opacity: .6; }
  50% { transform: translateY(8px); opacity: 1; }
}

/* 歌詞ステージ */
.skt-stage {
  min-height: 130vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px 28px;
}
.skt-lyrics {
  max-width: 460px;
  font-size: 1.5rem;
  line-height: 2.1;
  font-weight: 600;
  letter-spacing: .02em;
}
/* 初期は淡色。点灯すると桜色で浮かび上がる */
.th-word {
  color: #e7cdd6;
  transition: color .35s ease, text-shadow .35s ease;
}
.th-word.lit {
  color: #c4396a;
  text-shadow: 0 0 18px rgba(255,143,179,.55);
}

.skt-foot {
  text-align: center;
  padding: 24px 24px 80px;
}
.skt-foot p { font-family: "Segoe UI", sans-serif; font-size: .76rem; color: var(--pink-deep); letter-spacing: .06em; }

@media (prefers-reduced-motion: reduce) {
  .th-word { transition: none; }
  .skt-arrow { animation: none; }
}
JavaScript
// Sakura 歌詞:スクロール通過進捗に応じて単語を一語ずつ点灯
(() => {
  const scroller = document.getElementById('thScroller');
  const text = document.getElementById('thText');
  const words = text ? Array.from(text.querySelectorAll('.th-word')) : [];
  if (!scroller || !text || !words.length) return; // null安全

  function render() {
    const vh = scroller.clientHeight;
    // text の上端をスクロール領域の座標系で求める
    const top = text.offsetTop - scroller.scrollTop;
    // 文章が枠下から入り、上に抜けるまでを 0〜1 に正規化
    const start = vh * 0.85;
    const end = vh * 0.15;
    let progress = (start - top) / (start - end + text.offsetHeight);
    progress = Math.min(Math.max(progress, 0), 1);

    const litCount = Math.round(progress * words.length);
    words.forEach((w, i) => w.classList.toggle('lit', i < litCount));
  }

  let ticking = false;
  const onScroll = () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => { render(); ticking = false; });
  };
  scroller.addEventListener('scroll', onScroll, { passive: true });
  window.addEventListener('resize', render);
  render(); // 初期化

  // 操作がなくても点灯が伝わるよう、ゆっくり往復スクロール
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let auto = !reduce;
  let dir = 1;
  const stop = () => { auto = false; };
  ['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
    scroller.addEventListener(ev, stop, { passive: true }));

  if (auto) {
    setTimeout(function step() {
      if (!auto) return;
      const max = scroller.scrollHeight - scroller.clientHeight;
      if (max <= 0) return;
      scroller.scrollTop += dir * 1.6;
      if (scroller.scrollTop >= max - 1) dir = -1;
      else if (scroller.scrollTop <= 1) dir = 1;
      requestAnimationFrame(step);
    }, 700);
  }
})();

コード

HTML
<!-- 自前のスクロール領域(プレビュー枠内で完結) -->
<div class="th-scroller" id="thScroller">
<div class="th-spacer">&#8595; スクロールで文章が色づく</div>

<!-- スクロールに合わせて単語が1つずつハイライトされる -->
<section class="th-section">
  <p class="th-text" id="thText">
    <span class="th-word">スクロール</span>
    <span class="th-word">に</span>
    <span class="th-word">あわせて</span>
    <span class="th-word">言葉</span>
    <span class="th-word">が</span>
    <span class="th-word">ひとつ</span>
    <span class="th-word">ずつ</span>
    <span class="th-word">明るく</span>
    <span class="th-word">浮かび</span>
    <span class="th-word">あがる。</span>
    <span class="th-word">読み手</span>
    <span class="th-word">の</span>
    <span class="th-word">視線</span>
    <span class="th-word">を</span>
    <span class="th-word">やさしく</span>
    <span class="th-word">導く</span>
    <span class="th-word">演出。</span>
  </p>
</section>

<div class="th-spacer th-end">&#10003; おわり</div>
</div>
CSS
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --dim: #3a3a48;
  --lit: #fff7e6;
  --glow: #ffb454;
}

body {
  font-family: "Hiragino Sans", "Yu Gothic", "Segoe UI", system-ui, sans-serif;
  background: #15151d;
  color: #fff;
}

/* プレビュー枠を埋める自前スクロール領域 */
.th-scroller {
  width: 100%;
  height: 100vh;
  max-height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
}

.th-spacer {
  height: 220px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #777;
  font-size: .9rem;
  letter-spacing: .08em;
}
.th-end { color: var(--glow); }

/* 文章は中央に大きく */
.th-section {
  min-height: 120vh; /* 読み進める距離を確保 */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 26px;
}
.th-text {
  max-width: 560px;
  font-size: 1.7rem;
  line-height: 1.9;
  font-weight: 700;
}
.th-word {
  color: var(--dim);
  transition: color .35s ease, text-shadow .35s ease;
}
/* JSが付与する点灯クラス */
.th-word.lit {
  color: var(--lit);
  text-shadow: 0 0 18px rgba(255,180,84,.5);
}

@media (prefers-reduced-motion: reduce) {
  .th-word { transition: none; }
}
JavaScript
// 自前スクロール領域の通過進捗に応じて単語を点灯
(() => {
  const scroller = document.getElementById('thScroller');
  const text = document.getElementById('thText');
  const words = text ? Array.from(text.querySelectorAll('.th-word')) : [];
  if (!scroller || !text || !words.length) return; // null安全

  function render() {
    const vh = scroller.clientHeight;
    // text の上端をスクロール領域の座標系で求める
    const top = text.offsetTop - scroller.scrollTop;
    // 文章が枠下から入り、上に抜けるまでを 0〜1 に正規化
    const start = vh * 0.85;
    const end = vh * 0.15;
    let progress = (start - top) / (start - end + text.offsetHeight);
    progress = Math.min(Math.max(progress, 0), 1);

    const litCount = Math.round(progress * words.length);
    words.forEach((w, i) => w.classList.toggle('lit', i < litCount));
  }

  let ticking = false;
  const onScroll = () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => { render(); ticking = false; });
  };
  scroller.addEventListener('scroll', onScroll, { passive: true });
  window.addEventListener('resize', render);
  render(); // 初期化

  // 操作がなくても点灯が伝わるよう、ゆっくり往復スクロール
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let auto = !reduce;
  let dir = 1;
  const stop = () => { auto = false; };
  ['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
    scroller.addEventListener(ev, stop, { passive: true }));

  if (auto) {
    setTimeout(function step() {
      if (!auto) return;
      const max = scroller.scrollHeight - scroller.clientHeight;
      if (max <= 0) return;
      scroller.scrollTop += dir * 1.6;
      if (scroller.scrollTop >= max - 1) dir = -1;
      else if (scroller.scrollTop <= 1) dir = 1;
      requestAnimationFrame(step);
    }, 700);
  }
})();

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

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

# 追加してほしい効果
スクロール文章ハイライト(スクロール演出)
スクロール進捗に合わせて文章を単語ごとに点灯させ、読み手の視線を導きます。引用や物語の見せ場に。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 自前のスクロール領域(プレビュー枠内で完結) -->
<div class="th-scroller" id="thScroller">
<div class="th-spacer">&#8595; スクロールで文章が色づく</div>

<!-- スクロールに合わせて単語が1つずつハイライトされる -->
<section class="th-section">
  <p class="th-text" id="thText">
    <span class="th-word">スクロール</span>
    <span class="th-word">に</span>
    <span class="th-word">あわせて</span>
    <span class="th-word">言葉</span>
    <span class="th-word">が</span>
    <span class="th-word">ひとつ</span>
    <span class="th-word">ずつ</span>
    <span class="th-word">明るく</span>
    <span class="th-word">浮かび</span>
    <span class="th-word">あがる。</span>
    <span class="th-word">読み手</span>
    <span class="th-word">の</span>
    <span class="th-word">視線</span>
    <span class="th-word">を</span>
    <span class="th-word">やさしく</span>
    <span class="th-word">導く</span>
    <span class="th-word">演出。</span>
  </p>
</section>

<div class="th-spacer th-end">&#10003; おわり</div>
</div>

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

:root {
  --dim: #3a3a48;
  --lit: #fff7e6;
  --glow: #ffb454;
}

body {
  font-family: "Hiragino Sans", "Yu Gothic", "Segoe UI", system-ui, sans-serif;
  background: #15151d;
  color: #fff;
}

/* プレビュー枠を埋める自前スクロール領域 */
.th-scroller {
  width: 100%;
  height: 100vh;
  max-height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
}

.th-spacer {
  height: 220px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #777;
  font-size: .9rem;
  letter-spacing: .08em;
}
.th-end { color: var(--glow); }

/* 文章は中央に大きく */
.th-section {
  min-height: 120vh; /* 読み進める距離を確保 */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 26px;
}
.th-text {
  max-width: 560px;
  font-size: 1.7rem;
  line-height: 1.9;
  font-weight: 700;
}
.th-word {
  color: var(--dim);
  transition: color .35s ease, text-shadow .35s ease;
}
/* JSが付与する点灯クラス */
.th-word.lit {
  color: var(--lit);
  text-shadow: 0 0 18px rgba(255,180,84,.5);
}

@media (prefers-reduced-motion: reduce) {
  .th-word { transition: none; }
}

【JavaScript】
// 自前スクロール領域の通過進捗に応じて単語を点灯
(() => {
  const scroller = document.getElementById('thScroller');
  const text = document.getElementById('thText');
  const words = text ? Array.from(text.querySelectorAll('.th-word')) : [];
  if (!scroller || !text || !words.length) return; // null安全

  function render() {
    const vh = scroller.clientHeight;
    // text の上端をスクロール領域の座標系で求める
    const top = text.offsetTop - scroller.scrollTop;
    // 文章が枠下から入り、上に抜けるまでを 0〜1 に正規化
    const start = vh * 0.85;
    const end = vh * 0.15;
    let progress = (start - top) / (start - end + text.offsetHeight);
    progress = Math.min(Math.max(progress, 0), 1);

    const litCount = Math.round(progress * words.length);
    words.forEach((w, i) => w.classList.toggle('lit', i < litCount));
  }

  let ticking = false;
  const onScroll = () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => { render(); ticking = false; });
  };
  scroller.addEventListener('scroll', onScroll, { passive: true });
  window.addEventListener('resize', render);
  render(); // 初期化

  // 操作がなくても点灯が伝わるよう、ゆっくり往復スクロール
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let auto = !reduce;
  let dir = 1;
  const stop = () => { auto = false; };
  ['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
    scroller.addEventListener(ev, stop, { passive: true }));

  if (auto) {
    setTimeout(function step() {
      if (!auto) return;
      const max = scroller.scrollHeight - scroller.clientHeight;
      if (max <= 0) return;
      scroller.scrollTop += dir * 1.6;
      if (scroller.scrollTop >= max - 1) dir = -1;
      else if (scroller.scrollTop <= 1) dir = 1;
      requestAnimationFrame(step);
    }, 700);
  }
})();

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

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