スクロールに合わせて要素をふわっと出す演出は、Intersection Observer API で実装するのが2026年時点でも一番確実です。scrollイベントのように毎フレーム計算する必要がなく、コードも短く、全モダンブラウザで動きます。
CSSだけで書けるanimation-timeline: view()(スクロール駆動アニメーション)も登場していますが、Firefox が既定で無効のままなので、2026年9月時点では「Intersection Observer が本体、CSSスクロール駆動は上乗せ」という組み合わせが現実的です。
この記事では、動くコードでフェードインを作りながら、よくあるハマりどころ(コールバックの宣言順、transitionの置き場所、アクセシビリティ)まで一通り押さえます。
スポンサーリンク
Intersection Observer API とは何か
Intersection Observer API は、「指定した要素がビューポート(または任意の親要素)とどれだけ重なったか」をブラウザ側で監視し、条件を満たしたときだけコールバックを呼ぶ仕組みです。
ポイントは、監視の判定がメインスレッドのスクロールイベントから切り離されていることです。scrollイベントでgetBoundingClientRect()を毎回叩くと、レイアウトの再計算が頻発してカクつきの原因になります。Intersection Observer はその計算をブラウザに任せるので、スクロールがなめらかなまま「見えたら発火」を実現できます。
主なメソッド
new IntersectionObserver(callback, options):監視インスタンスを作るobserve(element):監視対象を追加するunobserve(element):その要素だけ監視をやめるdisconnect():全ての監視をまとめて解除するtakeRecords():未処理の交差情報を同期的に取り出す(使う場面は稀)
options の2つの要点
| オプション | 意味 | よく使う値 |
|---|---|---|
threshold |
何割見えたら発火するか | 0(1pxでも見えたら)/0.5(半分) |
rootMargin |
ビューポート境界の拡大・縮小 | "0px 0px -20% 0px"(少し遅らせる) |
root |
基準にする祖先要素 | null(=ビューポート) |
スクロールアニメーションで実際に触るのはほぼ threshold と rootMargin の2つです。
フェードインを実装する最小コード
結論のコードを先に出します。コールバックはIntersectionObserverを作る前に宣言してください。順序を逆にするとReferenceError: Cannot access 'callback' before initializationで落ちます(constのTDZ)。
HTML
<div class="fade-in-element">
スクロールで表示されるコンテンツ
</div>
CSS
transitionは初期状態のクラス側に書きます。到達状態の.is-visible側だけに書くと、要素が戻るときのアニメーションが効きません。
.fade-in-element {
opacity: 0;
transform: translateY(24px);
transition: opacity .6s ease-out, transform .6s ease-out;
}
.fade-in-element.is-visible {
opacity: 1;
transform: none;
}
JavaScript
const callback = (entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // 一度出したら監視を外す
});
};
const observer = new IntersectionObserver(callback, {
threshold: 0.2,
rootMargin: '0px 0px -10% 0px',
});
document
.querySelectorAll('.fade-in-element')
.forEach((el) => observer.observe(el));
querySelectorAll でまとめてobserve()している点も重要です。Intersection Observer は1つのインスタンスで何個でも監視できるので、要素ごとにインスタンスを作る必要はありません。
スポンサーリンク
JavaScriptが無効・失敗したときに要素が消えないようにする
この実装の最大の落とし穴は、JavaScriptが動かないとopacity: 0のまま本文が読めなくなることです。CDNの読み込み失敗やスクリプトエラーでも同じことが起きます。
対策はシンプルで、初期状態を隠すクラスはJavaScript側から付けるようにします。
/* JS が動いたときだけ隠す */
.js-anim .fade-in-element {
opacity: 0;
transform: translateY(24px);
transition: opacity .6s ease-out, transform .6s ease-out;
}
.js-anim .fade-in-element.is-visible {
opacity: 1;
transform: none;
}
document.documentElement.classList.add('js-anim');
これで、JavaScriptが1行も実行されなかった場合は「アニメーションなしで普通に表示される」状態に落ちます。SEO・アクセシビリティの両面で、この保険は入れておいた方がいいです。
prefers-reduced-motion への対応は必須か
結論から言うと必須です。OS側で「視差効果を減らす」を有効にしているユーザーには、動きが体調不良の原因になり得ます。
CSS側で1ブロック足すだけで対応できます。
@media (prefers-reduced-motion: reduce) {
.js-anim .fade-in-element {
opacity: 1;
transform: none;
transition: none;
}
}
JavaScript側から判定して、そもそも監視自体をやめる書き方もできます。
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
document
.querySelectorAll('.fade-in-element')
.forEach((el) => observer.observe(el));
}
スポンサーリンク
CSSのスクロール駆動アニメーションで置き換えられるか
まだ全面的には置き換えられません。 animation-timeline: view() は Chrome 115 / Edge 115 で搭載され、Safari も 26 で対応しましたが、Firefox は既定で無効(layout.css.scroll-driven-animations.enabled フラグの内側)のままです。MDN でも Baseline 入りしていない機能として扱われています。
したがって、実務での正解は「Intersection Observer をベースにして、対応ブラウザだけCSSに任せる」という段階的強化です。@supportsで分岐します。
@supports (animation-timeline: view()) {
.js-anim .fade-in-element {
opacity: 1;
transform: none;
transition: none;
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: none; }
}
CSS側が有効なブラウザでは、スクロール量に完全に追従する(=戻すと巻き戻る)アニメーションになり、メインスレッドをほとんど使いません。非対応ブラウザでは今まで通り Intersection Observer が動きます。
| 方式 | 対応状況 | 向いている用途 |
|---|---|---|
| Intersection Observer | 全モダンブラウザ | 一度きりの出現アニメ、要素追加、計測 |
animation-timeline: view() |
Chrome / Edge / Safari 26。Firefox はフラグ | スクロール量に追従する連続アニメ |
scrollイベント |
全ブラウザ | 今から選ぶ理由はほぼ無い |
パフォーマンスで気をつける3点
Intersection Observer 自体は軽いですが、アニメーションさせるプロパティの選び方で体感が変わります。
- 動かすのは
opacityとtransformだけにする:topやheightを動かすとレイアウトが再計算され、カクつきます - 出したら
unobserve()する:一度きりの演出なら監視を残す意味がありません。上のコードのようにコールバック内で外します - インスタンスは使い回す:要素100個に対して1つのObserverで十分です
ページ全体を離れるときはobserver.disconnect()で一括解除できます。SPAでルーティングする場合は、画面のアンマウント時に必ず呼んでください。
同じAPIの応用として、Intersection Observer APIで無限スクロールを実装する方法と遅延読み込みを最適化する方法も書いています。画像の遅延読み込みだけが目的なら、HTMLのネイティブ Lazy Loadingの方が手軽です。
まとめ
- スクロールアニメーションは Intersection Observer API が2026年時点の標準解。
scrollイベントを選ぶ理由はもう無い - コールバックは
IntersectionObserver生成より前に宣言する(constのTDZで落ちる) transitionは初期状態のクラス側に書く。動かすのはopacityとtransformだけ- 初期状態を隠すクラスはJSから付けて、JS失敗時に本文が消えないようにする
prefers-reduced-motion: reduceへの対応を必ず入れる- CSSの
animation-timeline: view()は Firefox 既定未対応。@supportsで上乗せする段階的強化にとどめる
最小構成なら20行程度で書けます。まずは上のコードをそのまま貼って、thresholdとrootMarginを自分のデザインに合わせて詰めていくのが早いです。