scrollytelling について
ウェブ制作者向けに、スクロール連動型のナラティブ体験を設計するスキルです。ストーリーのパターン、実装方法、アクセシビリティ、パフォーマンスを扱います。
主な機能
- 01スクロール連動の視覚表現5種
- 024種類のレイアウトパターン
- 03CSS、IntersectionObserver、GSAP、Motionの実装指針
- 04動きを抑える設定とアクセシビリティの指針
- 05パフォーマンスとモバイル対応の提案
活用シーン
- 01マルチメディア長編記事の企画
- 02製品ストーリーに適したレイアウト選び
- 03ウェブページへのアクセシブルなスクロール演出の追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ doodledood/ claude-code-plugins --skill scrollytellingインストール後、Claude Code で/scrollytellingを入力して読み込まれたか確認します。
詳細
スクロールテリングスキル
スクロールに応じてコンテンツを表示し、アニメーションをトリガーして、ユーザーがスクロールするにつれて没入感のあるストーリーテリング体験を生み出します。
スクロールテリングとは?
定義:「オンライン記事を読み進める際に、読者のスクロールに応じて視覚要素やテキスト要素が表示されたり、変化したりするストーリーテリング形式」です。読者がスクロールすると、従来のドキュメントの移動とは異なることが起こります。
起源:The New York Times の「Snow Fall: The Avalanche at Tunnel Creek」(2012年)。2013年のピューリッツァー賞特集記事部門を受賞しました。
効果がある理由:スクロールテリングは、人間が「自分でコントロールしたい」と望むという根本的な心理原則を活用します。スクロールするたびに小さな意思決定が行われ、エンゲージメントが高まります。ユーザーがペースをコントロールすることで、受動的にコンテンツを消費するよりも深いつながりが生まれます。
測定された効果:
- 静的なコンテンツと比べて、ページ滞在時間が400%増加
- 情報の想起率が67%向上
- ソーシャル共有率が5倍に増加
- コンバージョン完了率が25~40%向上
基本原則
1. 技術よりストーリーを優先する
最もよくある間違いは、物語ではなく技術を前面に出すことです。スクロールテリングはエフェクトを見せるのではなく、ストーリーを引き立てるものであるべきです。
2. ユーザーの主体性と段階的な情報開示
ユーザーがペースをコントロールします。好奇心を保つため、情報は少しずつ表示します。これにより、あらかじめ決められたペースから、ユーザーがコントロールする物語の流れへと変わります。
3. 順序立てた構成
階層構造のウェブコンテンツとは異なり、スクロールテリングには、明確な物語の区切りを持つ直線的な進行が必要です。各セクションは、前のセクションを踏まえて展開します。
4. 意味のある変化
スクロールをトリガーとするエフェクトは、すべて物語に貢献するものでなければなりません。目的のないアニメーションは、効果を高めるどころか、注意をそらします。
5. 派手さよりも抑制を
すべてのセクションにアニメーションを加える必要はありません。絶え間ないエフェクトよりも、控えめなトランジションのほうが効果的なこともよくあります。この形式は、コンテンツのメッセージを強調するものであり、妨げるものであってはなりません。
標準的な5つの手法
50件のスクロールテリング記事を分析した調査で、次の基本パターンが特定されました。
| 手法 | 説明 | 最適な用途 |
|---|---|---|
| グラフィックシーケンス | スクロール位置のしきい値に達すると、個別のビジュアルが完全に切り替わる | データビジュアライゼーション、段階的な解説 |
| アニメーショントランジション | 状態間が滑らかに変化する | 状態の変化、時間の経過に伴う変化 |
| パンとズーム | スクロールに応じて、ビジュアルのどの部分を表示するかが変わる | マップ、大きな画像、空間を軸とする物語 |
| ムービースクローラー | フレーム単位の進行によって、動画のような効果を生み出す | 製品紹介、3D オブジェクトの表示 |
| 表示と再生 | スクロール位置に応じて、インタラクティブ要素が有効になる | マルチメディア、音声・動画の統合 |
レイアウトパターン
パターン1:左右並列のスティッキー表示(最も一般的)
スクロールテリングの典型的なパターンです。物語のテキストが横でスクロールする間、グラフィックは「固定」されます。物語が終わると、グラフィックの固定が解除されます。
┌─────────────────────────────────────┐
│ ┌──────────┐ ┌─────────────────┐ │
│ │ Text │ │ │ │
│ │ Step 1 │ │ STICKY │ │
│ ├──────────┤ │ GRAPHIC │ │
│ │ Text │ │ │ │
│ │ Step 2 │ │ (updates with │ │
│ ├──────────┤ │ active step) │ │
│ │ Text │ │ │ │
│ │ Step 3 │ │ │ │
│ └──────────┘ └─────────────────┘ │
└─────────────────────────────────────┘
使用する場面:データビジュアライゼーションを用いたストーリー、段階的な解説、視覚的な文脈を保つ必要がある教育コンテンツ。
実装:パフォーマンスを高め、段階的な機能低下にも適切に対応するため、JavaScript のスクロールリスナーではなく、CSS の position: sticky を使用します。
パターン2:全幅セクション
コンテンツがビューポート全体に広がり、セクション単位で切り替わります。
使用する場面:ビジュアルを重視した物語、没入感のあるブランドストーリーテリング、ポートフォリオの紹介、タイムライン形式のストーリー。
パターン3:レイヤー化されたパララックス
複数の視覚レイヤー(背景、中景、前景)が異なる速度で動き、奥行きを生み出します。
使用する場面:雰囲気を重視したストーリーテリング、ゲームや製品の発表、奥行きが感情に訴える効果を加える長編の物語。
アクセシビリティに関する警告:パララックスは前庭障害(めまい、吐き気、片頭痛)を引き起こす可能性があります。動きを抑えた代替表示を必ず用意し、1ページにつき控えめなパララックス効果は最大1つに制限してください。
パターン 4: 多方向
垂直スクロールと水平セクションまたは横方向のタイムラインを組み合わせます。
使用する場面: タイムラインベースのコンテンツ、ビジュアル主導のショーケース、驚きがメッセージを強化する非従来的なレイアウト。
スクロリーテリングをいつ使用するか
適した候補:
- マルチメディアを伴うロングフォーム・ジャーナリズム
- 成果を称えるブランドストーリーテリング
- 機能を紹介する製品ページ
- 時系列・歴史的なコンテンツ
- 消化しやすい断片に分割された複雑なナラティブ
- 深みを必要とする高関与型の製品
避けるべき場合:
- 強力なビジュアル素材が不足している
- 時間や予算が厳しい(優れたスクロリーテリングにはより多くの投資が必要です)
- ストーリーに明確な時系列がない
- コンテンツが短い
- ローエンドデバイスでパフォーマンスが重要である
ヒアリング質問
実装する前に、ユーザーに確認してください:
header: "Scrollytelling Pattern"
question: "What scrollytelling pattern fits your narrative?"
options:
- "Pinned narrative - text changes while visual stays fixed (NYT, Pudding.cool style)"
- "Progressive reveal - content fades in as you scroll down"
- "Parallax depth - layers move at different speeds (requires reduced-motion fallback)"
- "Step sequence - discrete sections with transitions between"
- "Hybrid - multiple patterns combined"
header: "Tech Stack"
question: "What's your frontend setup?"
options:
- "React + Tailwind"
- "React + CSS-in-JS"
- "Next.js"
- "Vue"
- "Vanilla JS"
- "Other"
header: "Animation Approach"
question: "Animation library preference?"
options:
- "CSS-only (scroll-timeline API, IntersectionObserver) - best performance"
- "GSAP ScrollTrigger - most powerful, cross-browser"
- "Framer Motion / Motion - React ecosystem"
- "Lenis + custom - smooth scroll"
- "No preference - recommend based on complexity"
技術的な実装(2025-2026)
技術選定ガイド
| 複雑さ | 推奨 | バンドルサイズ |
|---|---|---|
| シンプルな表示、プログレスバー | ネイティブ CSS scroll-timeline | 0 KB |
| ビューポートでトリガーされるエフェクト | IntersectionObserver | 0 KB |
| 複雑なタイムライン、固定表示 | GSAP ScrollTrigger | 約 23 KB |
| React プロジェクト | Motion (Framer Motion) | 約 32 KB |
| スムーズスクロール + エフェクト | Lenis + GSAP | 約 25 KB |
CSS スクロール駆動アニメーション(ネイティブ - 2025 以降)
ブラウザサポート:
- Chrome 115 以降: 完全サポート(2025 年 7 月以降)
- Safari 26 以降: 完全サポート(2025 年 9 月以降)
- Firefox: フラグが必要(
layout.css.scroll-driven-animations.enabled)
主要なプロパティ:
animation-timeline: scroll()- アニメーションをスクロール位置にリンクしますanimation-timeline: view()- アニメーションを要素の可視性にリンクしますanimation-range- アニメーションの開始・停止を制御します
例 - ビュートリガーのフェードイン:
@supports (animation-timeline: scroll()) {
.reveal-on-scroll {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
例 - スクロール連動のプログレスバー:
.progress-bar {
animation: grow linear;
animation-timeline: scroll();
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
パフォーマンス上のメリット: Tokopedia はネイティブ CSS スクロール駆動アニメーションに切り替えることで、コード量を 80% 削減し、CPU 使用率が 50% から 2% に低下しました。
IntersectionObserver パターン
継続的なスクロール追跡を行わずにスクロールトリガーのエフェクトを実現するには:
const RevealOnScroll = ({ children, delay = 0 }) => {
const ref = useRef(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Check reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1, rootMargin: '-50px' }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(30px)',
transition: `all 0.6s ease ${delay}ms`,
}}
>
{children}
</div>
);
};
GSAP ScrollTrigger(複雑なアニメーション)
固定セクション、タイムラインのオーケストレーション、クロスブラウザの信頼性には:
const ScrollytellingSection = ({ steps }) => {
const containerRef = useRef(null);
const [activeStep, setActiveStep] = useState(0);
useEffect(() => {
// Check reduced motion preference
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}
const ctx = gsap.context(() => {
steps.forEach((_, index) => {
ScrollTrigger.create({
trigger: `.step-${index}`,
start: 'top center',
end: 'bottom center',
onEnter: () => setActiveStep(index),
onEnterBack: () => setActiveStep(index),
});
});
}, containerRef);
return () => ctx.revert();
}, [steps]);
return (
<section ref={containerRef} className="relative">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Text column - scrolls naturally */}
<div className="space-y-[100vh]">
{steps.map((step, i) => (
<div
key={i}
className={`step-${i} min-h-screen flex items-center transition-opacity duration-300 ${
activeStep === i ? 'opacity-100' : 'opacity-30'
}`}
>
<div className="max-w-md">
<h3 className="text-2xl font-bold mb-4">{step.title}</h3>
<p className="text-lg">{step.description}</p>
</div>
</div>
))}
</div>
{/* Visual column - sticky */}
<div className="relative hidden md:block">
<div className="sticky top-0 h-screen flex items-center justify-center">
<StepVisual step={activeStep} data={steps[activeStep]} />
</div>
</div>
</div>
</section>
);
};
React 向け Motion (Framer Motion)
import { motion, useScroll, useTransform } from 'motion/react';
const ScrollLinkedSection = () => {
const ref = useRef(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'],
});
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]);
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.8, 1, 0.8]);
return (
<section ref={ref} className="h-[200vh] relative">
<motion.div
className="sticky top-0 h-screen flex items-center justify-center"
style={{ opacity, scale }}
>
<h2 className="text-6xl font-bold">Scroll-Linked Content</h2>
</motion.div>
</section>
);
};
スクロール進捗フック
const useScrollProgress = (ref) => {
const [progress, setProgress] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const updateProgress = () => {
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight;
// 0 when element top enters viewport, 1 when bottom exits
const start = rect.top - windowHeight;
const end = rect.bottom;
const current = -start;
const total = end - start;
setProgress(Math.max(0, Math.min(1, current / total)));
};
window.addEventListener('scroll', updateProgress, { passive: true });
updateProgress();
return () => window.removeEventListener('scroll', updateProgress);
}, [ref]);
return progress;
};
アクセシビリティ要件
アクセシビリティは譲れません。スクロリーテリングは、正しく実装しないと前庭障害を引き起こしたり、キーボードやスクリーンリーダーのユーザーを排除したりする可能性があります。
重要な WCAG 基準
| 基準 | 要件 |
|---|---|
| SC 2.2.2 | 5 秒を超える自動再生コンテンツには一時停止・停止・非表示のコントロールが必要 |
| SC 2.3.3 | ユーザーがトリガーするアニメーションは無効化できなければならない |
| SC 2.3.1 | 1 秒に 3 回を超える点滅は禁止 |
モーション低減のサポート(必須)
常にユーザーの設定を尊重してください:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
scroll-behavior: auto !important;
}
}
const prefersReducedMotion = () =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// In components - check before animating
useEffect(() => {
if (prefersReducedMotion()) {
// Show content immediately, skip animations
return;
}
// Set up animations
}, []);
安全なアニメーションのガイドライン
| アニメーションの種類 | 安全性 |
|---|---|
| フェードイン・アウト、0.5 秒未満 | 安全 |
| シンプルなトランスフォーム | 安全 |
| パララックススクロール | 前庭障害を引き起こす - 常にフォールバックを提供 |
| 急降下・ズーム | 問題あり - 避けるかフォールバックを提供 |
| ループアニメーション | 認知負荷 - 繰り返し回数を制限 |
エフェクトは小さく保つ: ビューポートの 1/3 を超える範囲に影響するアニメーションは、ユーザーを圧倒する可能性があります。
キーボードナビゲーション
- スクロール可能な領域に
tabindex="0"を追加する - スムーズスクロールを使用する場合、フォーカスがスクロール対象に追従するようにする
- 主要なセクションへのスキップリンクを提供する
- スクロール領域にキーボードトラップを設けない
スクリーンリーダーへの配慮
- 適切な見出し階層を使用する(
<h1>から<h6>) - DOM の順序は論理的な読み上げ順序と一致させる
- 動的なコンテンツ更新には ARIA ライブリージョンを使用する
- すべてのコンテンツが DOM に存在するようにする(初期状態で視覚的に隠されていても)
パフォーマンスのベストプラクティス
推奨事項
- アニメーションには
transformとopacityを使用する(GPU アクセラレーション) - アニメーション対象の要素に
will-change: transformを控えめに追加する - スクロールリスナーには
passive: trueを使用する - JS ベースの固定ではなく
position: stickyを使用する - 画像や動画は必要になるまで遅延読み込みする
- 複数の要素に対して単一の IntersectionObserver を使用する
- デスクトップだけでなく、実機でテストする
避けるべき事項
width、height、top、leftのアニメーション(レイアウト再計算を引き起こす)will-changeの過剰な使用(メモリ使用量が増加する)- クリーンアップなしのスクロールリスナーの作成
- モーション低減設定への対応忘れ
- sticky 要素の祖先要素に対する
overflow: hiddenの使用
パフォーマンス目標
- First Contentful Paint: 2.5 秒未満
- スクロール中の 60fps の維持
- Core Web Vitals のしきい値の達成
モバイルへの配慮
モバイルユーザーはウェブトラフィックの 60% 以上を占めます。スクロリーテリングはモバイルで優れた動作をしなければ、大多数のユーザーを排除するリスクがあります。
モバイルファーストの設計思想
モバイルから始める: 「モバイルファーストから始めることで、体験を本質的な部分まで削ぎ落とし、必要なものだけを残すことが強制されます。これによりコンテンツが洗練され、焦点が定まります。」(The Pudding)
モバイル向けにコア体験を設計し、その後デスクトップ向けに強化します。その逆ではありません。このアプローチは:
- 必須コンテンツのみに絞った意思決定を強制する
- 開発効率を向上させる
- デスクトップが機能的に類似している場合、コード量が少なくなる
ビューポート単位: vh vs svh vs dvh vs lvh
モバイルブラウザはスクロール中にナビゲーションバーを切り替えるため、従来の 100vh レイアウトが崩れます。
| 単位 | 定義 | 使用場面 |
|---|---|---|
vh | 大きいビューポート(ブラウザ UI 非表示) | レガシーフォールバックのみ |
svh | 小さいビューポート(ブラウザ UI 表示) | レイアウトの約 90% に使用(推奨) |
lvh | 大きいビューポート(ブラウザ UI 非表示) | スペースを最大化するモーダル/オーバーレイ |
dvh | 動的ビューポート(絶えず変化) | 控えめに使用 - レイアウトスラッシングを引き起こす |
重要な警告: 「当初は『動的ビューポート単位が未来だ』と考え、すべての要素に dvh を使いました。これは間違いでした。絶え間ないレイアウトシフトは壊れているように感じられました。」
実装パターン:
.full-height-section {
height: 100vh; /* Fallback for older browsers */
height: 100svh; /* Modern solution - small viewport */
}
/* Progressive enhancement */
@supports (height: 100svh) {
:root {
--viewport-height: 100svh;
}
}
JavaScript による代替案(The Pudding の推奨):
function setViewportHeight() {
const vh = window.innerHeight;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
window.addEventListener('resize', setViewportHeight);
setViewportHeight();
.section {
height: calc(var(--vh) * 100);
}
タッチスクロールの物理特性
慣性スクロール: タッチを離した後もコンテンツはスクロールを続け、自然に減速します。iOS と Android では摩擦曲線が異なり、iOS の方がより「フリック感」があります。
iOS では重要:
.scroll-container {
overflow-y: auto;
-webkit-overflow-scrolling: touch; /* iOS momentum - still needed for pre-iOS 13 */
}
パフォーマンスに関する注意: iOS ではスクロールイベントは慣性スクロールの終了時に発火し、スクロール中には発火しません。ステップ検出にはスクロールリスナーではなく IntersectionObserver を使用してください。
ジェスチャーの競合の防止
プル・トゥ・リフレッシュの競合:
/* Disable PTR but keep bounce effects */
html {
overscroll-behavior-y: contain;
}
/* Or disable completely */
html {
overscroll-behavior-y: none;
}
モーダル内のスクロールチェイニング:
.modal-content {
overflow-y: auto;
overscroll-behavior: contain; /* Prevents scrolling parent when modal hits boundary */
}
横スワイプの競合(ブラウザの戻る/進む):
.horizontal-carousel {
touch-action: pan-y pinch-zoom; /* Allow vertical scroll & zoom, block horizontal */
}
パッシブイベントリスナー
Chrome 56 以降では、60fps のスクロールのためにタッチリスナーがデフォルトでパッシブになります。モニタリングにはパッシブリスナーを使用し、preventDefault() が必須の場合にのみ非パッシブを使用してください:
// ✅ Monitoring scroll (passive - default, best performance)
document.addEventListener('touchstart', trackTouch, { passive: true });
// ⚠️ Only when you MUST prevent default (e.g., custom swipe)
carousel.addEventListener('touchmove', handleSwipe, { passive: false });
JavaScript より CSS を優先する:
/* Better than JavaScript preventDefault */
.element {
touch-action: pan-y pinch-zoom;
}
モバイルでのスクロールスナップ
スクロールスナップはモバイルで mandatory と組み合わせるとうまく機能します(タッチデバイスでは proximity を避けてください):
.scroll-container {
scroll-snap-type: y mandatory;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.section {
scroll-snap-align: start;
min-height: 100svh;
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
.scroll-container {
scroll-snap-type: none;
scroll-behavior: auto;
}
}
警告: コンテンツがビューポートからはみ出す可能性がある場合は、mandatory を絶対に使用しないでください。ユーザーがスクロールして表示できなくなります。
タッチアクセシビリティ
最小タッチターゲットサイズ:
| 標準 | サイズ | 使用場面 |
|---|---|---|
| WCAG 2.5.8 (AA) | 24×24px | 最低限の準拠 |
| WCAG 2.5.5 (AAA) | 44×44px | ベストプラクティス |
| Apple iOS | 44×44pt | 推奨 |
| Android | 48×48dp | 推奨 |
視覚的なサイズを変えずにタッチ領域を拡大する:
.small-button {
width: 24px;
height: 24px;
padding: 10px; /* Creates 44×44px touch target */
}
ジェスチャーのみのアクションには常にボタンの代替手段を提供する:
<div class="item">
<span>Content</span>
<button aria-label="Delete">Delete</button>
</div>
ブラウザ固有の癖
iOS Safari:
/* Position sticky requires no overflow on ancestors */
.parent {
/* overflow: hidden; ❌ Breaks sticky on iOS */
}
.sticky {
position: -webkit-sticky; /* Prefix still needed */
position: sticky;
top: 0;
}
/* Preventing body scroll in modals requires JavaScript on iOS */
/* CSS overflow: hidden doesn't work on body */
// iOS modal scroll lock
function lockScroll() {
document.body.style.position = 'fixed';
document.body.style.top = `-${window.scrollY}px`;
}
Chrome Android:
/* Disable pull-to-refresh */
body {
overscroll-behavior-y: none;
}
レスポンシブレイアウト戦略
並列表示 → 積み重ねパターン:
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* On mobile: stacked, full-width */}
{/* On desktop: side-by-side sticky */}
<div className="space-y-[50vh] md:space-y-[100vh]">
{/* Text steps - shorter spacing on mobile */}
</div>
<div className="hidden md:block">
{/* Sticky visual - hidden on mobile, shown on desktop */}
</div>
</div>
CSS と JS のブレークポイントを同期させる:
const breakpoint = '(min-width: 800px)';
const isDesktop = window.matchMedia(breakpoint).matches;
if (isDesktop) {
initScrollama(); // Complex scrollytelling
} else {
initStackedView(); // Simple stacked layout
}
// Listen for breakpoint changes
window.matchMedia(breakpoint).addEventListener('change', (e) => {
if (e.matches) {
initScrollama();
} else {
initStackedView();
}
});
モバイル向けの代替パターン:
- 固定グラフィックを、テキストセクション間のインライングラフィックに置き換える
- 複雑なパララックスの代わりに、よりシンプルな表示アニメーションを使用する
- スクロールでキャプションが表示される静的な画像を積み重ねる
- スクローリーテリングがそもそも適切かどうかを検討する
モバイルパフォーマンス戦略
目標: 60fps (1 フレームあたり 16.7ms)
ハードウェアアクセラレーションされるプロパティのみを使用する:
.animate {
/* ✅ Good - GPU accelerated */
transform: translateY(100px);
opacity: 0.5;
/* ❌ Bad - triggers layout recalculation */
/* top: 100px; width: 200px; margin: 20px; */
}
will-change は控えめに使用する:
/* Only on elements about to animate */
.about-to-animate {
will-change: transform;
}
/* Remove when animation completes */
.animation-complete {
will-change: auto;
}
警告: コンポジットレイヤーが多すぎるとモバイルのパフォーマンスが低下します。すべてに will-change を適用しないでください。
requestAnimationFrame でスクロールハンドラーをスロットリングする:
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
updateAnimation();
ticking = false;
});
ticking = true;
}
}, { passive: true });
より良い方法: IntersectionObserver を使用する (スクロールイベントなし):
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateElement(entry.target);
}
});
});
モバイルでスクローリーテリングを簡素化または断念すべき場合
スクローリーテリングを維持する場合:
- トランジションが物語にとって本当に意味を持つ
- 空間的な動きや時間的な変化が理解の中核である
- パフォーマンス目標を達成できる (60fps、読み込み 3 秒未満)
- テストでモバイルユーザーがコンテンツを問題なく理解できることが示されている
スクローリーテリングを簡素化する場合:
- パフォーマンスは許容範囲だが、アニメーションが必須ではない
- ある程度の複雑さはあると嬉しいが、必須ではない
- デスクトップ体験はより豊かだが、モバイルは機能すれば十分である
スクローリーテリングを断念する場合:
- ミッドティア端末でパフォーマンスの問題が解決できない
- テストでモバイルユーザーが混乱したりいらいらしたりしている
- 開発スケジュールに適切な最適化の時間が確保できない
- コンテンツがよりシンプルな積み重ね形式でも同様に機能する
- アニメーションが装飾的であり、意味を持たない
「スクロールアニメーションを維持すべき最も重要な理由は、トランジションが単に見栄えを良くするためのものではなく、本当に意味を持つ場合です。」 (The Pudding)
モバイルテストチェックリスト
端末の網羅:
- iPhone (最新 2 モデル) - Safari
- iPhone - Chrome
- iPad - Safari (縦向き & 横向き)
- Android ハイエンド - Chrome
- Android ミッドティア - Chrome (パフォーマンスに重要)
- Android タブレット - Chrome
ビューポートのテスト:
- アドレスバーの表示/非表示のトランジションがスムーズ
- スクロール中にレイアウトのジャンプがない
- 固定要素が正しく配置されたままになる
- svh/dvh 単位が期待どおりに動作する
パフォーマンスのテスト:
- スクロール中に 60fps を維持 (Chrome DevTools の FPS メーターを使用)
- カクつくアニメーションがない
- 画像が適切に遅延読み込みされる
- 長時間のセッションでメモリリークしない
- CPU スロットリングでテストする (DevTools で 4 倍の低速化)
インタラクションのテスト:
- タッチスクロールが自然に感じられる (慣性)
- 誤操作がない
- 必要に応じて引っ張って更新を無効化する
- スクロールチェイニングが正しく動作する
アクセシビリティのテスト:
- モーションの低減を有効にしても動作する
- タッチターゲットが最小 44×44px
- すべてのジェスチャーにボタンの代替がある
- スクリーンリーダー (VoiceOver/TalkBack) で操作できる
重要: Chrome DevTools のモバイルエミュレーターは、ブラウザ UI の動作を正確にシミュレートしません。実機でテストするか、BrowserStack/Sauce Labs を使用してください
避けるべきアンチパターン
| アンチパターン | 問題 | 解決策 |
|---|---|---|
| スクロールジャッキング | 自然なスクロールを上書きし、アクセシビリティを損なう | ネイティブのスクロール動作を維持する |
| テキストとグラフィックの競合 | アニメーションを見ながらテキストを読めない | テキスト領域とアニメーション領域を分離する |
| アニメーションの過多 | コンテンツから注意をそらし、疲労を引き起こす | 抑制する — すべてのセクションにエフェクトが必要なわけではない |
| 長さの指標がない | ユーザーがどれだけの量かを把握できない | 進行状況インジケーターを追加する |
| フォールバックの欠如 | no-JS やモーション低減のユーザーで壊れる | プログレッシブエンハンスメント |
| モバイルの軽視 | ユーザーの 60% を排除する | モバイルファーストの設計 |
| ペース配分の悪さ | コンテンツの表示が速すぎたり遅すぎたりする | 実際のユーザーでテストする |
実装ワークフロー
- ストーリーを理解する - どのようなストーリーを伝えますか?どのような順序で展開しますか?
- パターンを選ぶ - ピン留め、プログレッシブ、パララックス、またはハイブリッドのどれにしますか?
- アクセシビリティを計画する - モーションを減らす設定を使用しているユーザーには、どのような体験を提供しますか?
- テクノロジーを選定する - 複雑さに応じて、ネイティブ CSS、GSAP、Motion を選びます
- 構造を組み立てる - まず HTML/コンポーネントの構造を構築します
- スクロールの仕組みを追加する - トラッキング(IntersectionObserver、ScrollTrigger など)を実装します
- アニメーションを組み込む - スクロール状態と視覚的な変化を連動させます
- モーションを減らす設定向けの代替手段を追加する - アニメーションなしでもコンテンツが機能するようにします
- パフォーマンスを監査する - カクつきを確認し、最適化します
- デバイス間でテストする - モバイル、タブレット、デスクトップ、および各種ブラウザーでテストします
- アクセシビリティをテストする - キーボード操作、スクリーンリーダー、モーションを減らす設定を確認します
出力
要件を収集した後、コードベースにスクロリーテリング体験を直接実装します。以下を提供します。
- スクロールトラッキングを備えたコンポーネント構造
- モーションを減らす設定への対応を含むアニメーション/トランジションのロジック
- レスポンシブ対応の調整(モバイルファースト)
- アクセシブルな代替手段
- パフォーマンスの最適化
- テストに関する推奨事項
参考となる注目事例
- NYT「Snow Fall」 - 始まりとなった事例、マルチメディアの統合
- Pudding.cool - 音声と映像を同期させたデータジャーナリズム
- National Geographic「Atlas of Moons」 - 教育向け、レスポンシブデザイン
- BBC「Partition of India」 - マルチメディアを用いた歴史物語
- Firewatch のウェブサイト - 雰囲気を演出する多層パララックス
出典
EU Data Visualization Guide、MDN、GSAP のドキュメント、W3C WCAG、A List Apart、Smashing Magazine、The Pudding、CSS-Tricks、スクロリーテリングの有効性に関する学術研究など、100 件以上の情報源に基づく調査。