tech-book-labs
UI / インタラクション · 最終検証 2026-07-26 · scrollama 3.2.0 · 初公開 2026-07-26

Scrollama でスクロール連動のデータストーリーを作る

scrollama を使って、スクロールに合わせて sticky なグラフが切り替わる scrollytelling(スクロール連動の読み物)を React で実装するパターン。IntersectionObserver ベースの step 検出、offset の意味、sticky レイアウトの CSS、resize / モバイル対応の落とし穴まで、4 ステップで棒グラフが変化する触れる demo 付きで整理する実装メモ。

scrollama scrollytelling intersection-observer data-story react

検証日: 2026-07-26

使用バージョン: scrollama@3.2.0 / react@19.2

対象: データ可視化を「読み物」として届けたい人。ニュースメディアのインタラクティブ記事のような、スクロールで図が進む形式を自分のサイトに足したい人

scrollytelling(スクロールに連動して図・地図が切り替わる記事形式)を scrollama で実装する。scrollama は scroll イベントではなく IntersectionObserver(要素が viewport と交差した瞬間を検知するブラウザ API)を使う軽量ライブラリで、step 検出のロジックだけを提供し、描画には一切関与しない。React の state 更新と組み合わせる最小構成を、動く demo で確認する。

触って試す

右側(モバイルは下)のカードをスクロールすると、左の sticky なグラフが「並べ替え → 強調 → 軸の切り替え」と 4 段階で変化する。グラフは 1 つの SVG ではなく普通の div + CSS transition で、scrollama は「いまどの step か」の index を返しているだけ。

1. 全体像 — 登場する 3 つの部品

scrollytelling の実装は、役割の違う 3 つの部品に分かれる:

部品役割実装
sticky graphic画面に固定され続ける図CSS position: sticky
step 要素スクロールで通過するテキストカード群普通の div(高さを十分とる)
scrollamastep が発火ラインを跨いだ瞬間を通知onStepEnter で index を受け取る

scrollama がやるのは 3 番目だけ。「図をどう変えるか」は完全に自分のコード(React の state → 再レンダー)なので、グラフライブラリは Recharts でも D3 でも MapLibre でも同じ形で組める。

2. 最小サンプル(セットアップ 2 ステップ)

pnpm add scrollama@3.2.0

次のコードは .step 要素が viewport の 60% ラインを跨ぐたびに index を state に入れる最小の React 実装:

import { useEffect, useState } from "react";
import scrollama from "scrollama";

export function Story() {
  const [step, setStep] = useState(0);

  useEffect(() => {
    const scroller = scrollama();
    scroller
      .setup({
        step: ".step",     // セレクタ文字列 or 要素の配列
        offset: 0.6,       // viewport 上端から 60% の位置が発火ライン
      })
      .onStepEnter(({ index, direction }) => setStep(index));

    const onResize = () => scroller.resize();
    window.addEventListener("resize", onResize);
    return () => {
      window.removeEventListener("resize", onResize);
      scroller.destroy();   // ← observer の解除。SPA では必須
    };
  }, []);

  return (
    <div className="grid grid-cols-2">
      <div className="sticky top-0 h-screen">{/* step に応じた図 */}</div>
      <div>
        {["導入", "展開", "結論"].map((t, i) => (
          <div key={t} className="step" style={{ minHeight: "80vh", opacity: step === i ? 1 : 0.4 }}>
            {t}
          </div>
        ))}
      </div>
    </div>
  );
}

ポイント:

  • offset: 0.6 = 「viewport の上から 60% の高さの水平線」が発火ライン。0 に近いほど画面上部で、1 に近いほど画面下部で発火する。読み物では 0.5〜0.7 が読みやすい
  • onStepEnterindex(何番目の step か)と direction(up / down)を返す。逆スクロールで戻ったときも発火するので、**状態は「累積」ではなく「index から計算」**で持つ
  • scroller.destroy() を cleanup で必ず呼ぶ。呼ばないと unmount 後も observer が生き残る

3. sticky レイアウトの CSS

scrollytelling の見た目は scrollama ではなく CSS で決まる。押さえるのは 2 点:

(1) graphic 側は position: sticky。次のコードは 2 カラム構成の骨格:

.container { display: grid; grid-template-columns: 1fr 1fr; }
.graphic   { position: sticky; top: 4rem; height: 70vh; align-self: start; }
.steps     { display: flex; flex-direction: column; gap: 40vh; }
  • align-self: start が重要。grid item はデフォルトで stretch され、sticky が効かなくなる(親と同じ高さになると「貼り付く余地」がない)
  • step 間の gap は viewport 単位(40vh 等)で取る。px で固定すると画面の高さ次第で 2 step が同時に発火ラインを跨ぐ

(2) モバイルは 1 カラム + 図を上に固定grid-template-columns: 1fr に落とし、graphic を top: 0 の sticky にして、step カードが図の上を流れていく形にするのが定番(この記事の demo は md 未満でこの形になる)。

4. step に応じて図を変える

scrollama から届くのは index だけなので、「step ごとの図の状態」を宣言的に持つと管理しやすい。次のコードは step ごとの表示設定をデータとして定義するパターン:

const VIEWS = [
  { sort: false, highlight: 0, axis: "raw" },      // step 0: 素直に表示
  { sort: true,  highlight: 0, axis: "raw" },      // step 1: 並べ替え
  { sort: true,  highlight: 3, axis: "raw" },      // step 2: 上位 3 件を強調
  { sort: true,  highlight: 0, axis: "perKb" },    // step 3: 別の軸へ
] as const;

// render 側は VIEWS[step] を読むだけ
const view = VIEWS[step];

変化のアニメーションは CSS transition に任せるのが最も安い。demo の棒グラフは widthbackgroundtransition: width 600ms ease を当てているだけで、D3 の enter/update/exit のような遷移管理は書いていない。要素の同一性(React の key)が保たれていれば、並び替えも CSS だけで滑らかに見える

5. 進捗が欲しい場合: onStepProgress

step 内のスクロール量(0〜1)を連続値で受け取ることもできる。地図の視点をスクロール量に比例して滑らかに動かす、といった演出に使う:

scroller
  .setup({ step: ".step", progress: true })
  .onStepProgress(({ index, progress }) => {
    // progress: その step 内での位置 0〜1
    map.setBearing(progress * 90);
  });

連続値で state を更新すると再レンダーが毎スクロールで走るので、React では useRef + 直接 DOM/インスタンス操作(上の例のように map インスタンスを直接叩く)にした方が滑らか。

つまずいたポイント

  • step が 1 つも発火しない: step 要素の高さ不足が定番。viewport より十分低い要素が連続していると、observer 的には「全部同時に見えている」状態になり境界を跨がない。step 間の余白を 40vh 以上とる
  • リサイズ後に発火位置がずれる: scrollama は setup 時の寸法で発火ラインを計算する。window.addEventListener("resize", () => scroller.resize()) を必ず入れる(demo でも入れている)
  • React 19 + StrictMode で observer が二重になる: dev では effect が 2 回走るので、cleanup で destroy() していないと onStepEnter が 2 回ずつ呼ばれる。destroy を入れていれば 2 回目の setup だけが生き残り問題ない
  • sticky が効かない: 親のどこかに overflow: hidden があると sticky は無効化される。prose 系の CSS フレームワーク内に埋めるときは not-prose で切り出してから確認する
  • 一気にスクロールすると step を飛ばす: 高速スクロールでは中間 step の enter が飛ぶことがある。「step 3 の状態は step 1〜2 の累積」のような設計にせず、どの step index が来ても単独で完全な状態を計算できる形(§4 の VIEWS 方式)にしておくと壊れない

向くケース / 向かないケース

判定
データの「読ませ方」を段階的に誘導したい(記事型)◎ 本命
地図・チャートの状態遷移を見せたい◎ MapLibre / Recharts と好相性
単発のスクロールアニメーション(フェードイン等)△ CSS の animation-timelineIntersectionObserver 単体で足りる
スクロールジャック(スクロール量の乗っ取り)✖ scrollama は意図的に非対応。ユーザのスクロールは奪わない設計

関連書籍

tech-book.net /books/9784873118086

Python と JavaScriptではじめるデータビジュアライゼーション | tech-book.net

Kyran Dale/嶋田 健志/木下 哲也

関連テーマ・学習ロードマップから次の一冊へ。

詳細を tech-book.net で見る
tech-book.net /books/9784873119380

Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス | tech-book.net

Alex Banks/Eve Porcello/宮崎 空

関連テーマ・学習ロードマップから次の一冊へ。

詳細を tech-book.net で見る