ナラティブ・スクロール

スクロール進行で章(シーン)が切り替わり、背景色と見出しテキストがフェードで入れ替わる没入演出。3〜4シーンで物語のように読み進めるストーリーテリングUIです。

#scroll#narrative#storytelling

ライブデモ

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

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

HTML
<!-- Sakura 結成ストーリー。スクロールで章が切り替わり背景色と見出しが入れ替わる -->
<div class="skn-scroller" id="narScroller">
  <!-- 固定背景:JSで色が切り替わる -->
  <div class="skn-bg" id="narBg" aria-hidden="true"></div>

  <!-- 固定の見出しステージ:各章の見出しがフェードで入れ替わる -->
  <div class="skn-stage" aria-hidden="true">
    <div class="skn-headlines" id="narHeadlines">
      <h2 class="nar-headline">出会いの春</h2>
      <h2 class="nar-headline">積み重ねた日々</h2>
      <h2 class="nar-headline">はじめてのステージ</h2>
      <h2 class="nar-headline">そして、いま</h2>
    </div>
  </div>

  <!-- スクロールで通過する各章(高さでスクロール量を作る) -->
  <section class="nar-scene skn-scene" data-scene="0" data-color="#ffd1e0">
    <span class="skn-chapter">CHAPTER 01</span>
    <p class="skn-caption">5人が出会ったのは、桜が満開の放課後だった。スクロールして物語を進めよう。</p>
  </section>
  <section class="nar-scene skn-scene" data-scene="1" data-color="#ffb6cd">
    <span class="skn-chapter">CHAPTER 02</span>
    <p class="skn-caption">レッスンの毎日。転んでも、また立ち上がって。</p>
  </section>
  <section class="nar-scene skn-scene" data-scene="2" data-color="#ff8fb3">
    <span class="skn-chapter">CHAPTER 03</span>
    <p class="skn-caption">小さな会場、満員のお客さん。緊張のなか、ライトが灯る。</p>
  </section>
  <section class="nar-scene skn-scene" data-scene="3" data-color="#ff6f9c">
    <span class="skn-chapter">CHAPTER 04</span>
    <p class="skn-caption">あの春から、わたしたちは Sakura になった。これからもよろしくね。</p>
  </section>
</div>
CSS
:root {
  --scene-bg: #ffd1e0; /* 現在の章の背景。JSで上書き */
}

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

body {
  font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", sans-serif;
  color: #fff;
  background: #ffd1e0;
}

/* 自前スクロール領域 */
.skn-scroller {
  position: relative;
  width: 100%;
  height: 100vh;
  max-height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: thin;
  scrollbar-color: #ff6f9c transparent;
}

/* 固定背景:色がなめらかに切り替わる */
.skn-bg {
  position: sticky;
  top: 0;
  height: 100vh;
  max-height: 100%;
  margin-bottom: -100vh; /* 後続シーンを上に重ねる */
  background: var(--scene-bg);
  transition: background 1s ease;
  z-index: 0;
}
/* 花びらの淡い装飾を重ねる */
.skn-bg::after {
  content: "";
  position: absolute; inset: 0;
  background-image:
    radial-gradient(circle at 18% 25%, rgba(255,255,255,.5) 0 3px, transparent 4px),
    radial-gradient(circle at 72% 40%, rgba(255,255,255,.4) 0 2px, transparent 3px),
    radial-gradient(circle at 50% 80%, rgba(255,255,255,.35) 0 3px, transparent 4px);
  background-size: 180px 180px;
  opacity: .6;
}

/* 固定の見出しステージ */
.skn-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  max-height: 100%;
  margin-bottom: -100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1;
  pointer-events: none;
}
.skn-headlines {
  position: relative;
  width: 100%;
  text-align: center;
}
/* 見出しを重ねて配置し、active のみ表示 */
.nar-headline {
  position: absolute;
  left: 0; right: 0;
  top: 50%;
  font-size: clamp(1.8rem, 8vw, 3rem);
  font-weight: 800;
  letter-spacing: .08em;
  color: #fff;
  opacity: 0;
  transform: translateY(calc(-50% + 24px));
  transition: opacity .7s ease, transform .7s cubic-bezier(.2,.8,.2,1);
  text-shadow: 0 6px 26px rgba(196,57,106,.45);
}
.nar-headline.is-active {
  opacity: 1;
  transform: translateY(-50%);
}

/* スクロール量を生む各章 */
.skn-scene {
  position: relative;
  z-index: 2;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  padding-bottom: 12%;
}
.skn-chapter {
  font-size: .66rem;
  letter-spacing: .3em;
  color: #fff;
  background: rgba(196,57,106,.35);
  padding: 4px 14px;
  border-radius: 999px;
}
.skn-caption {
  max-width: 78%;
  text-align: center;
  font-size: .88rem;
  line-height: 1.8;
  color: #fff;
  background: rgba(196,57,106,.28);
  padding: 10px 16px;
  border-radius: 12px;
  backdrop-filter: blur(2px);
}

@media (prefers-reduced-motion: reduce) {
  .skn-bg, .nar-headline { transition: none; }
}
JavaScript
// Sakura 結成ストーリー:中央に来た章に合わせて背景色と見出しを切り替える
(() => {
  const scroller = document.getElementById('narScroller');
  const bg = document.getElementById('narBg');
  const scenes = Array.from(document.querySelectorAll('.nar-scene'));
  const headlines = Array.from(document.querySelectorAll('.nar-headline'));
  if (!scroller || !bg || !scenes.length) return; // null安全

  function activate(scene) {
    const i = Number(scene.dataset.scene) || 0;
    // 背景色を切替
    bg.style.setProperty('--scene-bg', scene.dataset.color || '#ffd1e0');
    // 対応する見出しのみ表示
    headlines.forEach((h, idx) => h.classList.toggle('is-active', idx === i));
  }

  if ('IntersectionObserver' in window) {
    const io = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) activate(entry.target);
      });
    }, { root: scroller, threshold: 0.55 });
    scenes.forEach(s => io.observe(s));
  }
  activate(scenes[0]); // 初期章

  // 操作がなくても章の切替が見えるよう、ゆっくり自動スクロール
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let auto = !reduce;
  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 (scroller.scrollTop >= max - 1) return;
      scroller.scrollTop += 2.2;
      requestAnimationFrame(step);
    }, 800);
  }
})();

コード

HTML
<!-- 自前スクロール領域(プレビュー枠内で完結) -->
<div class="nar-scroller" id="narScroller">
  <!-- 固定背景:JSで色が切り替わる -->
  <div class="nar-bg" id="narBg" aria-hidden="true"></div>

  <!-- 固定の見出しステージ:各シーンの見出しがフェードで入れ替わる -->
  <div class="nar-stage" aria-hidden="true">
    <div class="nar-headlines" id="narHeadlines">
      <h2 class="nar-headline">夜明け前</h2>
      <h2 class="nar-headline">光が差す</h2>
      <h2 class="nar-headline">街が動き出す</h2>
      <h2 class="nar-headline">日は高く昇る</h2>
    </div>
  </div>

  <!-- スクロールで通過する各シーン(高さでスクロール量を作る) -->
  <section class="nar-scene" data-scene="0" data-color="#101426" data-label="夜明け前">
    <p class="nar-caption">物語はまだ眠りの中。スクロールして次の章へ。</p>
  </section>
  <section class="nar-scene" data-scene="1" data-color="#3a2c5e" data-label="光が差す"></section>
  <section class="nar-scene" data-scene="2" data-color="#9c5a3a" data-label="街が動き出す"></section>
  <section class="nar-scene" data-scene="3" data-color="#d99a3a" data-label="日は高く昇る">
    <p class="nar-caption">章が切り替わるたび、背景と見出しが入れ替わります。</p>
  </section>
</div>
CSS
:root {
  --scene-bg: #101426; /* 現在シーンの背景。JSで上書き */
}

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

body {
  font-family: "Segoe UI", system-ui, sans-serif;
  color: #fff;
  background: #101426;
}

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

/* 固定背景:色がなめらかに切り替わる */
.nar-bg {
  position: sticky;
  top: 0;
  height: 100vh;
  max-height: 100%;
  margin-bottom: -100vh; /* 後続シーンを上に重ねる */
  background: var(--scene-bg);
  transition: background 1s ease;
  z-index: 0;
}

/* 固定の見出しステージ */
.nar-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  max-height: 100%;
  margin-bottom: -100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1;
  pointer-events: none;
}

/* 見出しを重ねて配置し、active のみ表示 */
.nar-headlines {
  position: relative;
  width: 100%;
  text-align: center;
}
.nar-headline {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  font-size: clamp(1.8rem, 8vw, 3.4rem);
  font-weight: 800;
  letter-spacing: .06em;
  opacity: 0;
  transform: translateY(calc(-50% + 24px));
  transition: opacity .7s ease, transform .7s cubic-bezier(.2,.8,.2,1);
  text-shadow: 0 6px 26px rgba(0, 0, 0, .35);
}
.nar-headline.is-active {
  opacity: 1;
  transform: translateY(-50%);
}

/* スクロール量を生むシーン */
.nar-scene {
  position: relative;
  z-index: 2;
  height: 100vh;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding-bottom: 12%;
}
.nar-caption {
  max-width: 80%;
  text-align: center;
  font-size: .9rem;
  line-height: 1.7;
  color: rgba(255, 255, 255, .8);
  background: rgba(0, 0, 0, .22);
  padding: 10px 16px;
  border-radius: 12px;
  backdrop-filter: blur(2px);
}

@media (prefers-reduced-motion: reduce) {
  .nar-bg, .nar-headline { transition: none; }
}
JavaScript
// 自前スクロール領域内で、中央に来たシーンに合わせて背景色と見出しを切り替える
(() => {
  const scroller = document.getElementById('narScroller');
  const bg = document.getElementById('narBg');
  const scenes = Array.from(document.querySelectorAll('.nar-scene'));
  const headlines = Array.from(document.querySelectorAll('.nar-headline'));
  if (!scroller || !bg || !scenes.length) return; // null安全

  function activate(scene) {
    const i = Number(scene.dataset.scene) || 0;
    // 背景色を切替
    bg.style.setProperty('--scene-bg', scene.dataset.color || '#101426');
    // 対応する見出しのみ表示
    headlines.forEach((h, idx) => h.classList.toggle('is-active', idx === i));
  }

  if ('IntersectionObserver' in window) {
    const io = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) activate(entry.target);
      });
    }, { root: scroller, threshold: 0.55 });
    scenes.forEach(s => io.observe(s));
  }
  activate(scenes[0]); // 初期シーン

  // 操作がなくても章の切替が見えるよう、ゆっくり自動スクロール
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let auto = !reduce;
  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 (scroller.scrollTop >= max - 1) return;
      scroller.scrollTop += 2.2;
      requestAnimationFrame(step);
    }, 800);
  }
})();

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

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

# 追加してほしい効果
ナラティブ・スクロール(スクロール演出)
スクロール進行で章(シーン)が切り替わり、背景色と見出しテキストがフェードで入れ替わる没入演出。3〜4シーンで物語のように読み進めるストーリーテリングUIです。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 自前スクロール領域(プレビュー枠内で完結) -->
<div class="nar-scroller" id="narScroller">
  <!-- 固定背景:JSで色が切り替わる -->
  <div class="nar-bg" id="narBg" aria-hidden="true"></div>

  <!-- 固定の見出しステージ:各シーンの見出しがフェードで入れ替わる -->
  <div class="nar-stage" aria-hidden="true">
    <div class="nar-headlines" id="narHeadlines">
      <h2 class="nar-headline">夜明け前</h2>
      <h2 class="nar-headline">光が差す</h2>
      <h2 class="nar-headline">街が動き出す</h2>
      <h2 class="nar-headline">日は高く昇る</h2>
    </div>
  </div>

  <!-- スクロールで通過する各シーン(高さでスクロール量を作る) -->
  <section class="nar-scene" data-scene="0" data-color="#101426" data-label="夜明け前">
    <p class="nar-caption">物語はまだ眠りの中。スクロールして次の章へ。</p>
  </section>
  <section class="nar-scene" data-scene="1" data-color="#3a2c5e" data-label="光が差す"></section>
  <section class="nar-scene" data-scene="2" data-color="#9c5a3a" data-label="街が動き出す"></section>
  <section class="nar-scene" data-scene="3" data-color="#d99a3a" data-label="日は高く昇る">
    <p class="nar-caption">章が切り替わるたび、背景と見出しが入れ替わります。</p>
  </section>
</div>

【CSS】
:root {
  --scene-bg: #101426; /* 現在シーンの背景。JSで上書き */
}

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

body {
  font-family: "Segoe UI", system-ui, sans-serif;
  color: #fff;
  background: #101426;
}

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

/* 固定背景:色がなめらかに切り替わる */
.nar-bg {
  position: sticky;
  top: 0;
  height: 100vh;
  max-height: 100%;
  margin-bottom: -100vh; /* 後続シーンを上に重ねる */
  background: var(--scene-bg);
  transition: background 1s ease;
  z-index: 0;
}

/* 固定の見出しステージ */
.nar-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  max-height: 100%;
  margin-bottom: -100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1;
  pointer-events: none;
}

/* 見出しを重ねて配置し、active のみ表示 */
.nar-headlines {
  position: relative;
  width: 100%;
  text-align: center;
}
.nar-headline {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  font-size: clamp(1.8rem, 8vw, 3.4rem);
  font-weight: 800;
  letter-spacing: .06em;
  opacity: 0;
  transform: translateY(calc(-50% + 24px));
  transition: opacity .7s ease, transform .7s cubic-bezier(.2,.8,.2,1);
  text-shadow: 0 6px 26px rgba(0, 0, 0, .35);
}
.nar-headline.is-active {
  opacity: 1;
  transform: translateY(-50%);
}

/* スクロール量を生むシーン */
.nar-scene {
  position: relative;
  z-index: 2;
  height: 100vh;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding-bottom: 12%;
}
.nar-caption {
  max-width: 80%;
  text-align: center;
  font-size: .9rem;
  line-height: 1.7;
  color: rgba(255, 255, 255, .8);
  background: rgba(0, 0, 0, .22);
  padding: 10px 16px;
  border-radius: 12px;
  backdrop-filter: blur(2px);
}

@media (prefers-reduced-motion: reduce) {
  .nar-bg, .nar-headline { transition: none; }
}

【JavaScript】
// 自前スクロール領域内で、中央に来たシーンに合わせて背景色と見出しを切り替える
(() => {
  const scroller = document.getElementById('narScroller');
  const bg = document.getElementById('narBg');
  const scenes = Array.from(document.querySelectorAll('.nar-scene'));
  const headlines = Array.from(document.querySelectorAll('.nar-headline'));
  if (!scroller || !bg || !scenes.length) return; // null安全

  function activate(scene) {
    const i = Number(scene.dataset.scene) || 0;
    // 背景色を切替
    bg.style.setProperty('--scene-bg', scene.dataset.color || '#101426');
    // 対応する見出しのみ表示
    headlines.forEach((h, idx) => h.classList.toggle('is-active', idx === i));
  }

  if ('IntersectionObserver' in window) {
    const io = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) activate(entry.target);
      });
    }, { root: scroller, threshold: 0.55 });
    scenes.forEach(s => io.observe(s));
  }
  activate(scenes[0]); // 初期シーン

  // 操作がなくても章の切替が見えるよう、ゆっくり自動スクロール
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let auto = !reduce;
  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 (scroller.scrollTop >= max - 1) return;
      scroller.scrollTop += 2.2;
      requestAnimationFrame(step);
    }, 800);
  }
})();

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

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