Scrollama でスクロール連動のデータストーリーを作る
scrollama を使って、スクロールに合わせて sticky なグラフが切り替わる scrollytelling(スクロール連動の読み物)を React で実装するパターン。IntersectionObserver ベースの step 検出、offset の意味、sticky レイアウトの CSS、resize / モバイル対応の落とし穴まで、4 ステップで棒グラフが変化する触れる demo 付きで整理する実装メモ。
検証日: 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(高さを十分とる) |
| scrollama | step が発火ラインを跨いだ瞬間を通知 | 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 が読みやすいonStepEnterはindex(何番目の 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 の棒グラフは width と background に transition: 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-timeline や IntersectionObserver 単体で足りる |
| スクロールジャック(スクロール量の乗っ取り) | ✖ scrollama は意図的に非対応。ユーザのスクロールは奪わない設計 |
関連書籍
Python と JavaScriptではじめるデータビジュアライゼーション | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。