Scrollytelling

2026/03/25

概要

現在大手メディアの中で有料ネット配信サービスに最も成功したとされるNew York Times(NYT)についてGeminiとチャットをしている中で、 Scrollytelling(スクローリーテリング)は読者が記事をスクロールしていくと、テキストの進行に合わせて背景の映像がシームレスに切り替わったり、アニメーションが進行したりします。NYTの「ビジュアル・インベスティゲーション」で多用される、最も没入感の高い構成です。という

Scrollytellingは、スクロール量に応じて視覚要素が表示されるため、長文や複雑なデータでもユーザーが楽しみながら物語を深く理解できる特徴があるとされる。NYTが最初に導入し現在では多くのデータ報道で使われている。

自分でも手作りのhtml/cssを使ってサイトを運営しており、興味を持ったのでGeminiのプログラムスキルを利用して同様の手法でページを作ってみた。

Scrollytellingサンプルコード

それぞれの画像についてアスペクト比 16対9(pc用) と 9対16(スマホ用) の2種類の画像を準備する。

アスペクト比 16対9(pc用)

アスペクト比9対16(スマホ用)

Scrollytellingサンプルコード <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Hitoshi Diary - 四季の野鳥ギャラリー</title> <style> /* ========================================================= 1. 基本スタイル(スマホデフォルト - Mobile First) ========================================================= */ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: #000; color: #fff; overflow-x: hidden; } .sticky-background { position: sticky; top: 0; width: 100%; height: 100vh; z-index: 1; overflow: hidden; } #bg-image { width: 100%; height: 100%; object-fit: cover; transition: opacity 0.5s ease; opacity: 1; } .scroll-content { position: relative; z-index: 2; margin-top: -100vh; /* テキスト全体を上に1画面分引っ張り上げる */ } .step { height: 100vh; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8vh; box-sizing: border-box; } .step-text { background-color: rgba(0, 0, 0, 0.4); padding: 20px; border-radius: 12px; width: 60%; max-width: 400px; font-size: 1rem; line-height: 1.7; text-align: left; box-shadow: 0 4px 15px rgba(0,0,0,0.5); } .step-text h2 { margin-top: 0; font-size: 1.2rem; color: #fff; } .step-text p { margin-bottom: 0; color: #ccc; } /* 表紙(ガイド)用の特別スタイル */ .step.intro { align-items: center; /* 表紙はスマホでも画面の中央に配置 */ } .step.intro .step-text { text-align: center; /* 文字を中央揃えに */ background-color: rgba(0, 0, 0, 0.6); /* 少し背景を濃くして読みやすく */ } /* スクロールを促す矢印のアニメーション */ .scroll-prompt { display: block; margin-top: 20px; font-weight: bold; color: #fff; animation: bounce 2s infinite; /* ふわふわ動かす */ } @keyframes bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-10px); } 60% { transform: translateY(-5px); } } /* ========================================================= 2. PC用スタイル(画面幅 769px 以上で適用) ========================================================= */ @media screen and (min-width: 769px) { .step { align-items: center; padding-bottom: 0; justify-content: flex-start; } .step-text { width: 20%; max-width: 400px; margin-left: 5%; padding: 40px; font-size: 1.15rem; } .step-text h2 { font-size: 1.2rem; } /* PC版での表紙の配置調整(中央に寄せる) */ .step.intro { justify-content: center; } .step.intro .step-text { margin-left: 0; } } </style> </head> <body> <div class="sticky-background"> <img id="bg-image" src="" alt="背景画像"> </div> <div class="scroll-content"> <div class="step intro" data-bg-pc="birdflower/collage_16-9.jpg" data-bg-sp="birdflower/collage_9-16.jpg"> <div class="step-text"> <h2>四季の野鳥アルバム</h2> <p>このページはスクロールに連動して景色が変わります。<br>ゆっくりと画面を下にスクロールして、<br>季節の移ろいをお楽しみください。</p> <span class="scroll-prompt">↓ スクロールしてスタート ↓</span> </div> </div> <div class="step" data-bg-pc="birdflower/mejiro_16_9.jpg" data-bg-sp="birdflower/mejiro_9_16.jpg"> <div class="step-text"> <h2>春の訪れ(メジロと桜)</h2> <p>日本の春の象徴的な風景。淡いピンクの桜の中に、鮮やかな黄緑色のメジロが映える、明るく繊細な一枚。</p> </div> </div> <div class="step" data-bg-pc="birdflower/kawasemi_16_9.jpg" data-bg-sp="birdflower/kawasemi_9_16.jpg"> <div class="step-text"> <h2>水辺の宝石(カワセミと蓮)</h2> <p>静寂な水辺。鮮やかなコントラスト(カワセミの青、蓮のピンク、葉の緑)と、シャープな質感。</p> </div> </div> <div class="step" data-bg-pc="birdflower/hachidori_16_9.jpg" data-bg-sp="birdflower/hachidori_9_16.jpg"> <div class="step-text"> <h2>夏の躍動(ハチドリとノウゼンカズラ)</h2> <p>ホバリングするハチドリの一瞬を捉えた、ダイナミックで色彩豊かな野生の瞬間</p> </div> </div> <div class="step" data-bg-pc="birdflower/shijyugara_16_9.jpg" data-bg-sp="birdflower/shijyugara_9_16.jpg"> <div class="step-text"> <h2>秋の叙情(シジュウカラと野菊)</h2> <p>秋の柔らかな光の中。野菊の可憐さと、シジュウカラの少し落ち着いた色合いが調和する、温かみのある一枚。</p> </div> </div> <div class="step" data-bg-pc="birdflower/uso_16_9.jpg" data-bg-sp="birdflower/uso_9_16.jpg"> <div class="step-text"> <h2>冬の静寂(ウソと寒椿)</h2> <p>雪の降る冬。真っ赤な寒椿と、ウソ(鳥)のピンク色の喉元が、白一色の世界で際立つ。</p> </div> </div> <div class="step" data-bg-pc="birdflower/fukurou_16_9.jpg" data-bg-sp="birdflower/fukurou_9_16.jpg"> <div class="step-text"> <h2>静寂の夜(フクロウと月下美人)</h2> <p>夜にしか咲かない神秘的な花「月下美人」と、夜の支配者「フクロウ」の組み合わせ。暗闇の中で白く浮かび上がる花と、フクロウの鋭い眼光が、幻想的で静謐な世界観を表現します。</p> </div> </div> <div class="step" data-bg-pc="birdflower/misosazai_16_9.jpg" data-bg-sp="birdflower/misosazai_9_16.jpg"> <div class="step-text"> <h2>渓流の躍動(ミソサザイと苔、岩、小さな野花)</h2> <p>渓流の岩場。苔むした岩、冷たい水、そして小さな野花。その厳しい環境で力強く生きる、日本最小級の野鳥「ミソサザイ」の躍動感を捉えます。</p> </div> </div> <div class="step" data-bg-pc="birdflower/oohashi_16_9.jpg" data-bg-sp="birdflower/oohashi_9_16.jpg"> <div class="step-text"> <h2>異国の彩り(極楽鳥花とオオハシ)</h2> <p>熱帯雨林の鮮烈な色彩。極楽鳥花(ストレリチア)のユニークな形と、オオハシの巨大なクチバシ。形と色の「奇抜さ」をテーマにした、大胆な一枚。</p> </div> </div> <div class="step" data-bg-pc="birdflower/raicho_16_9.jpg" data-bg-sp="birdflower/raicho_9_16.jpg"> <div class="step-text"> <h2>高山の生命(ライチョウとコマクサ)</h2> <p>北アルプスのような3000m級の高山帯。雪解け直後の砂礫地に立つライチョウと、その足元にひっそりと、しかし力強く咲く「高山植物の女王」コマクサ。霧に包まれた厳しい環境で際立つ「生命の色彩」と、ライチョウの凛とした佇まいを捉えます。</p> </div> </div> </div> <script> const bgImage = document.getElementById('bg-image'); const steps = document.querySelectorAll('.step'); const THRESHOLD_WIDTH = 768; let activeStep = null; // ページを開いた瞬間に、最初の画像(表紙)をデバイスに合わせて即座に表示する function initFirstImage() { const firstStep = steps[0]; // 最初の表紙ブロックを取得 const isMobile = window.innerWidth <= THRESHOLD_WIDTH; // デバイスに合ったURLをsrcに直接セットする(フェードインのラグを無くすため) bgImage.src = isMobile ? firstStep.getAttribute('data-bg-sp') : firstStep.getAttribute('data-bg-pc'); activeStep = firstStep; } initFirstImage(); // スクロールを待たずに即実行 // デバイスに応じて最適な画像のURLを設定する共通関数 function setBackgroundImage(stepElement) { if (!stepElement) return; const isMobile = window.innerWidth <= THRESHOLD_WIDTH; const newBgSrc = isMobile ? stepElement.getAttribute('data-bg-sp') : stepElement.getAttribute('data-bg-pc'); if (bgImage.src !== newBgSrc) { bgImage.style.opacity = 0; setTimeout(() => { bgImage.src = newBgSrc; bgImage.style.opacity = 1; }, 300); } } const observerOptions = { root: null, rootMargin: "-50% 0px -50% 0px", threshold: 0 }; const observerCallback = (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { activeStep = entry.target; setBackgroundImage(activeStep); } }); }; const observer = new IntersectionObserver(observerCallback, observerOptions); steps.forEach(step => observer.observe(step)); let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { setBackgroundImage(activeStep); }, 200); }); </script> </body> </html>

JavaScripの部分を詳細に説明して

ご提示いただいたJavaScriptコードは、Webページをスクロールした際に、背景画像がフワッと切り替わる「スクローリーテリング(スクロール連動型コンテンツ)」を実現するための心臓部です。 また、スマートフォンとPCで適切なサイズの画像を読み分ける(レスポンシブ対応)機能や、ページの動作を軽くするための工夫も盛り込まれた、非常に実用的なプロ仕様のコードになっています。 全体を5つのブロックに分けて、それぞれの役割を詳細に解説します。 1. 準備:HTML要素と設定の読み込み JavaScript const bgImage = document.getElementById('bg-image'); const steps = document.querySelectorAll('.step'); const THRESHOLD_WIDTH = 768; let activeStep = null; ここでは、JavaScriptが操作する対象を捕まえて、設定の基準を作っています。 bgImage: 背景を表示する タグを取得しています。 steps: スクロールに合わせて流れてくるテキストのブロック(段落)をすべて取得しています。 THRESHOLD_WIDTH: スマホとPCを区別するための境界線です。ここでは画面の横幅が768ピクセルを基準としています。 activeStep: 「現在、画面のど真ん中にあるテキストブロック」を記憶しておくための空の箱です。 2. ロード直後の処理:表紙の即時表示 JavaScript function initFirstImage() { const firstStep = steps[0]; const isMobile = window.innerWidth <= THRESHOLD_WIDTH; bgImage.src = isMobile ? firstStep.getAttribute('data-bg-sp') : firstStep.getAttribute('data-bg-pc'); activeStep = firstStep; } initFirstImage(); ページを開いた瞬間に、画面が真っ暗にならないようにするための重要な処理です。 一番最初のテキストブロック(steps[0])のデータを見に行きます。 現在の画面幅を確認し、スマホ(data-bg-sp)かPC(data-bg-pc)のどちらの画像URLを使うかを決定します。 アニメーションの時間を待たずに、即座に背景(bgImage.src)に画像をセットします。 3. メイン機能:背景画像の切り替えとアニメーション JavaScript function setBackgroundImage(stepElement) { if (!stepElement) return; const isMobile = window.innerWidth <= THRESHOLD_WIDTH; const newBgSrc = isMobile ? stepElement.getAttribute('data-bg-sp') : stepElement.getAttribute('data-bg-pc'); if (bgImage.src !== newBgSrc) { bgImage.style.opacity = 0; setTimeout(() => { bgImage.src = newBgSrc; bgImage.style.opacity = 1; }, 300); } } ここが画像をフワッと切り替えるためのメインエンジンです。 まず、次に表示すべき画像が現在表示されている画像と「違う」場合のみ動作するように条件分岐(if (bgImage.src !== newBgSrc))しています。これにより無駄な処理を防ぎます。 アニメーションの仕組み: 1. まず opacity = 0 で現在の画像を透明にします。 2. setTimeout を使い、0.3秒後(300ミリ秒後)に画像のURL(src)を新しいものに差し替えます。 3. 同時に opacity = 1 で再び不透明に戻すことで、クロスフェードのような滑らかな切り替えを実現しています。 4. スクロールの監視:Intersection Observer JavaScript const observerOptions = { root: null, rootMargin: "-50% 0px -50% 0px", threshold: 0 }; const observerCallback = (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { activeStep = entry.target; setBackgroundImage(activeStep); } }); }; const observer = new IntersectionObserver(observerCallback, observerOptions); steps.forEach(step => observer.observe(step)); 「今、どのテキストが読まれているか?」をブラウザの機能を使って監視(Observe)する、最も現代的で動作が軽い手法です。 rootMargin: "-50% 0px -50% 0px": これが一番の魔法です。画面の上から50%、下から50%の位置、つまり**「画面のド真ん中に引いた見えない横線」**をセンサーとして設定しています。 テキストブロックがこの「画面のド真ん中」に触れた(isIntersecting が true になった)瞬間に、先ほどの setBackgroundImage 関数を呼び出して画像を切り替えます。 5. リサイズ時の調整:Debounce(遅延処理) JavaScript let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { setBackgroundImage(activeStep); }, 200); }); スマホの向きを縦から横に変えたり、PCのブラウザの幅をマウスで伸縮させたりした時の対応です。 ウィンドウサイズが変わると、画面が「スマホサイズ」から「PCサイズ」を跨ぐ可能性があります。その際、現在表示中の画像を適切なデバイス用のものに再読み込みさせます。 clearTimeout と setTimeout の組み合わせ: これは「デバウンス(Debounce)」と呼ばれるプロのテクニックです。ブラウザの幅をマウスで引き延ばしている間、resize イベントは1秒間に何十回も発生します。その度に画像を再計算するとサイトがフリーズしてしまうため、「サイズ変更が終わってから0.2秒(200ミリ秒)待って、1回だけ処理を実行する」というストッパーの役割を果たしています。


最初から全部の画像をロードしないでスクロール毎にロードするのはどこですか

Webサイトの表示速度や読者のデータ通信量を節約する上で、その「最初から全部読み込まない(遅延読み込み=Lazy Loading)」という仕組みは極めて重要です。 結論から言うと、このプログラムでは「HTMLの書き方」と「JavaScriptの特定の1行」の組み合わせが、その遅延読み込みの役割を完璧に果たしています。 具体的には、以下の2つの連携プレーで行われています。 1. 【HTMLの工夫】URLを「src」ではなく「data属性」に隠す 通常、画像を読み込ませる時は <img src="画像のURL"> と書きます。ブラウザは src を見つけると、ページを開いた瞬間に手当たり次第にダウンロードを開始してしまいます。 しかし今回のコードでは、各段落のHTMLを以下のように書いています。 <div class="step" data-bg-pc="nyt/kawasemi_16_9.jpg" data-bg-sp="nyt/kawasemi_9_16.jpg"> このように、画像のURLを src ではなく data-bg-pc というオリジナルのデータ置き場(名前札)に隠しています。 ブラウザにとってこれはただの文字列(テキスト)なので、ページを開いた時点では一切画像をダウンロードしません。(※最初の表紙のメジロの画像だけは表示します) 2. 【JSの工夫】スクロールした瞬間に「src」に代入する そして、読者がスクロールして該当のブロックが画面の中央に来た(交差した)瞬間、JavaScriptの setBackgroundImage 関数の中にある以下のコードが発動します。 JavaScript // (略)スマホかPCか判定して、先ほど隠しておいたURLを取り出す const newBgSrc = isMobile ? stepElement.getAttribute('data-bg-sp') : stepElement.getAttribute('data-bg-pc'); // (略)古い画像を透明にした後、0.3秒後に以下の行が実行される bgImage.src = newBgSrc; // ← ★ここが発動ポイント! この bgImage.src = newBgSrc; という1行が実行され、初めて本物の src にカワセミやハチドリの画像URLがセットされます。 ブラウザは「あっ、新しい画像URLがsrcに入った!」と気づき、まさにその瞬間に初めてサーバーへ画像をダウンロードしに行きます。 まとめ つまり、「画像をどこでロードしているか?」に対する答えは、読者がスクロールして目標に到達した瞬間に実行される bgImage.src = newBgSrc; の部分ということになります。 この仕組みのおかげで、例えば写真が100枚ある巨大なページを作ったとしても、読者が途中で読むのをやめれば、残りの写真は一切ダウンロードされず、通信量もサーバーへの負荷も最小限に抑えられます。

サンプル「四季の野鳥アルバム」

四季の野鳥アルバム

コラージュ作成プログラム

ついでに初期画像として使う汎用的なコラージュ生成プログラムを作った。

pc用とスマホ用コラージュ

コラージュ作成プログラム

<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>万能コラージュメーカー</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background-color: #f4f4f9; color: #333; display: flex; flex-direction: column; align-items: center; padding: 20px; margin: 0; } .container { background: #fff; padding: 30px; border-radius: 12px; box-shadow: 0 8px 20px rgba(0,0,0,0.1); max-width: 900px; width: 100%; text-align: center; } h1 { font-size: 1.5rem; margin-top: 0; } .controls { margin: 20px 0; display: flex; justify-content: center; flex-wrap: wrap; /* スマホなどで折り返すように */ gap: 15px; } input[type="file"] { display: none; } .btn, .select-ratio { background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 1rem; font-weight: bold; transition: background 0.3s; font-family: inherit; } .select-ratio { background-color: #495057; /* 少し色を変えて区別 */ appearance: none; /* デフォルトの矢印を消す */ -webkit-appearance: none; } .select-ratio:hover { background-color: #343a40; } .btn:hover { background-color: #0056b3; } .btn-download { background-color: #28a745; } .btn-download:hover { background-color: #1e7e34; } .btn:disabled { background-color: #ccc; cursor: not-allowed; } /* キャンバス(プレビュー)の枠。縦長になっても画面に収まるよう工夫 */ .canvas-wrapper { background-color: #eee; padding: 10px; border-radius: 8px; margin-top: 20px; } canvas { max-width: 100%; max-height: 65vh; /* 画面の高さを超えないように制限 */ width: auto; height: auto; border: 1px solid #ddd; box-shadow: 0 4px 10px rgba(0,0,0,0.05); display: block; margin: 0 auto; } .instruction { font-size: 0.9rem; color: #666; } </style> </head> <body> <div class="container"> <h1>自動コラージュメーカー</h1> <p class="instruction">画像を複数選択し、用途に合わせてアスペクト比を変更してください。</p> <div class="controls"> <select id="ratioSelect" class="select-ratio"> <option value="16:9" selected>PC用 (16:9)</option> <option value="9:16">スマホ用 (9:16)</option> <option value="4:3">標準写真 (4:3)</option> <option value="1:1">正方形 (1:1)</option> </select> <label class="btn"> 画像を選ぶ <input type="file" id="imageInput" multiple accept="image/*"> </label> <button id="downloadBtn" class="btn btn-download" disabled>コラージュを保存</button> </div> <div class="canvas-wrapper"> <canvas id="collageCanvas"></canvas> </div> </div> <script> const imageInput = document.getElementById('imageInput'); const ratioSelect = document.getElementById('ratioSelect'); const canvas = document.getElementById('collageCanvas'); const ctx = canvas.getContext('2d'); const downloadBtn = document.getElementById('downloadBtn'); let currentImages = []; // アップロードされた画像を記憶しておく変数 // 選ばれた比率に合わせてキャンバスの実際のサイズ(画素数)を設定する関数 function updateCanvasSize() { const ratio = ratioSelect.value; if (ratio === '16:9') { canvas.width = 1920; canvas.height = 1080; } else if (ratio === '9:16') { canvas.width = 1080; canvas.height = 1920; } else if (ratio === '4:3') { canvas.width = 1600; canvas.height = 1200; } else if (ratio === '1:1') { canvas.width = 1200; canvas.height = 1200; } } // キャンバスを白紙に戻す関数 function clearCanvas() { ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height); } // 画像が選択された時の処理 imageInput.addEventListener('change', async (e) => { const files = Array.from(e.target.files); if (files.length === 0) return; downloadBtn.disabled = true; // 選択されたファイルをImageオブジェクトとして読み込み、記憶する currentImages = await Promise.all(files.map(file => { return new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(img); img.src = URL.createObjectURL(file); }); })); // キャンバスのサイズを合わせてから描画 updateCanvasSize(); clearCanvas(); drawCollage(currentImages); downloadBtn.disabled = false; }); // アスペクト比のプルダウンが変更された時の処理 ratioSelect.addEventListener('change', () => { updateCanvasSize(); clearCanvas(); // すでに画像が読み込まれていれば、新しい比率で再描画する if (currentImages.length > 0) { drawCollage(currentImages); } }); // キャンバスに画像を配置して描画する関数 function drawCollage(images) { const numImages = images.length; if (numImages === 0) return; // 画像の枚数に合わせてグリッド(行と列)を計算 const cols = Math.ceil(Math.sqrt(numImages)); const rows = Math.ceil(numImages / cols); const cellWidth = canvas.width / cols; const cellHeight = canvas.height / rows; images.forEach((img, index) => { const col = index % cols; const row = Math.floor(index / cols); const x = col * cellWidth; const y = row * cellHeight; // 画像を枠いっぱいにトリミングして描画 drawImageCover(ctx, img, x, y, cellWidth, cellHeight); // 画像の間に白い枠線を引く ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 10; ctx.strokeRect(x, y, cellWidth, cellHeight); }); } // 画像の比率を保ったまま枠にトリミング(cover)する関数 function drawImageCover(ctx, img, x, y, w, h) { const imgRatio = img.width / img.height; const targetRatio = w / h; let sx, sy, sWidth, sHeight; if (imgRatio > targetRatio) { sHeight = img.height; sWidth = img.height * targetRatio; sx = (img.width - sWidth) / 2; sy = 0; } else { sWidth = img.width; sHeight = img.width / targetRatio; sx = 0; sy = (img.height - sHeight) / 2; } ctx.drawImage(img, sx, sy, sWidth, sHeight, x, y, w, h); } // ダウンロードボタンの処理 downloadBtn.addEventListener('click', () => { // 現在の比率名をファイル名に組み込む(例: collage_16-9.jpg) const ratioName = ratioSelect.value.replace(':', '-'); const fileName = `collage_${ratioName}.jpg`; const dataURL = canvas.toDataURL('image/jpeg', 0.9); const link = document.createElement('a'); link.download = fileName; link.href = dataURL; link.click(); }); // 最初に画面を開いた時の初期設定 updateCanvasSize(); clearCanvas(); </script> </body> </html>

感想

スマホでスクロールしながら画面いっぱいに背景画像として表示すると予想以上に没入感があり、Geminiが最初にNYTのサイト構成手法で優れているとしていたのに納得する。


戻る
.