ブラウザのスクロールに応じてアニメーションを実行する方法【Javascript/Intersection Observer】

css

ブラウザをスクロールすると、文字や画像がふわっと表示されるサイトがあります。
こういったスクロールの量によって発火するイベントはjQueryのプラグインを使っていました。
今回、jQueryを使用しない実装方法を調べたのでメモ。

Intersection Observerとは

今回、スクロールイベントを実装するにあたって、Intersection Observer APIを使用します。
IEなどの古いブラウザで実装する場合、別途プラグインを読み込ませる必要がありますが、現在主流のブラウザは対応しているので、気にせずに使えそうです。

なお、Intersectionとは「交差」、Observerは「監視者」という意味があります。
文字通りターゲット要素を監視し、親要素と交差したらイベントを発生させることができます。

基本的な使い方

Intersection Observerを使うには、まず、ターゲット要素が親要素に交差したときに実行されるコールバック関数が必要になります。
コールバック関数はIntersection Observerをインスタンス化する時に指定します。また、observe()メソッドの引数に、ターゲット要素を渡します。

構文は以下の通りとなります。

全体

//オプション
const options = {
  root: null, //親要素
  rootMargin: '0px', //親要素のマージン
  threshold: 0 //ターゲット要素がどれくらいの割合で表示されているか
}

//コールバック関数
function callback(entries, observer) {
  entries.forEach(entry => {
    if(entry.isIntersecting) {
        //ターゲット要素が親要素に交差した時の処理
    } else {
      //ターゲット要素が親要素に交差していない時の処理
    }
  })
}

//ターゲット要素をDOMで取得
const target = document.querySelector('.target');
//インスタンス化
const obs = new IntersectionObserver(callback, options);
//ターゲット要素の監視を開始
obs.observe(target);

インスタンス化

まず、IntersectionObserverをインスタンス化します。その際、コールバック関数とオプションを指定します。オプションの設定によって、親要素との交差の位置を調整できます。

//インスタンス化
const obs = new IntersectionObserver(callback, options)

ターゲット要素の監視を開始

IntersectionObserverをインスタンス化したら、observe()メソッドにターゲット要素を指定します。これだけで要素の監視が開始されます。

//ターゲット要素をDOMで取得
const target = document.querySelector('.target');
//ターゲット要素の監視を開始
obs.observe(target);

コールバック関数

ターゲット要素が親要素に交差したら実行する関数です。アニメーションなど、要素の動きはここで設定します。

//コールバック関数
function callback(entries, observer) {
  entries.forEach(entry => {
    if(entry.isIntersecting) {
        //ターゲット要素が親要素に交差した時の処理
    } else {
      //ターゲット要素が親要素に交差していない時の処理
    }
  })
}

引数entriesには、observe()メソッドで引き渡したターゲット要素が格納されています。
ターゲット要素は複数設定が可能です。そのため値が配列で渡ってきます。
for文で1つずつ要素を取り出して設定します。

親要素にターゲット要素が交差しているかはentry.isIntersectingで判定できます。
isIntersecting以外もプロパティがあるらしいですが、ここでは省略します。
アニメーションの動きを指定したクラス名をターゲット要素に追加or削除することで、スクロールに応じたアニメーションの実行を制御します。

オプションの使い方

オブションの設定で、親要素を明確に指定したり、ターゲット要素との交差点を変更することができます。例として簡単なスクロールアニメーションをいくつか作成しました。

thresholdオプションでターゲット要素の表示率によってイベントを発生させる

thresholdオプションを指定すると、ターゲット要素が親要素のどれくらいまで表示されたかでイベントを発生させることができます。デフォルトは0です。ターゲット要素が親要素に交差した時点でイベントが発生します。指定する数字はターゲット要素の表示率を表します。

尚、親要素はデフォルト(ブラウザ全体)にします。

0 → 1pxでもターゲットが親要素に表示されたらイベントが発生(デフォルト)
1.0 → ターゲット要素が100%表示されたらイベントが発生

const options = {
    root: null,
    threshold: 0 //1pxでもターゲットが親要素に表示されたらイベントが発生(デフォルト)
};

値は配列で複数指定できます。以下はターゲット要素が親要素に入ってきた時、50%表示された時、100%表示された時の3回イベントが発生することを表します。

const options = {
    root: null,
    threshold: [0, 0.5, 1]
};

このオプションの動作を分かりやすくしたサンプルがこちらです。イベントが発生したら背景色を変更しています。

See the Pen IntersectionObserver-threshold by donguri2020 (@m-ke) on CodePen.

ターゲットが親要素に入ってきた時、50%表示された時、100%表示された時にイベントが発生していることが確認できます。

rootオプションで親要素を指定する

rootオプションで、親要素を指定できます。オプションで親要素を指定しない、もしくはnullを指定した場合、親要素はブラウザ全体になります。
以下は、親要素を#contentにした例です。

const options = {
  root: document.querySelector('#content'),
  threshold: 0
}

thresholdオプションがデフォルトのため、ターゲット要素が親要素#contentに1pxでも表示された時(交差した時点)でイベントが発生します。

イメージ的には以下の図のような感じです。ターゲット要素が「交差」に接した時、イベントが発生します。

親要素#contentにターゲット要素が交差したらイベント発生

実際の動きは以下で確認できます。

See the Pen IntersectionObserver-root by donguri2020 (@m-ke) on CodePen.

交差した瞬間にクラスが追加され背景色が変更するので、アニメーション開始を少し遅らせています。

rootMarginで親要素の余白を指定する

rootMarginオプションで、親要素に余白を指定できます。指定方法はCSSのmarginと同じです。
以下の場合、親要素に上下-200pxの余白を指定しています。
親要素はデフォルト(ブラウザ全体)とします。

const options = {
  root: null,
  rootMargin: '-200px 0px', //上下200px、左右0の余白 必ず単位(px、%)が必要
}

本来なら、ターゲット要素がブラウザに交差した時点でイベントが発生しますが、親要素に上下-200pxの余白があるので、本来の位置から200px遅れてイベントが発生します。
また、値を正の数にすると、200px手前でイベントが発生します。

実際の動きは以下で確認できます。ドットが表示されてから200pxほどスクロールしたらアニメーションが開始します。

See the Pen IntersectionObserver-rootMargin by donguri2020 (@m-ke) on CodePen.

ちなみに私の環境ではCodePenでうまく動作しませんでした。。同じドットを以下に掲載します。

ドットが表示されてから200pxほどスクロールするとアニメーションが開始されると思います。
わかりやすくするため、ドットから200pxほど下にグレーで色をつけています。
※正確にはドットの高さ18pxをマイナスした182pxを設定しています。

 
 
 

スクロールイベントで動きのあるページのサンプル

今までのまとめとして、動きのあるそれっぽいページを作成しました。

実際の動作はこちらでご確認ください!(別ウィンドウが開きます)

Javascript

Javascriptのコードは短いです。これだけで動きのあるページが作成できるのはスバラシイです!

const contents = document.querySelectorAll(".content");

// スクロール感知で実行
const cb = function(entries, observer) {
    entries.forEach(entry => {
        if(entry.isIntersecting) {
            entry.target.classList.add('animate');
            observer.unobserve(entry.target); //監視の終了
        }
    });
}
// オプション
const options = {
    root: null,
    rootMargin: "0px",
    threshold: 0.3
}

// IntersectionObserverインスタンス化
const io = new IntersectionObserver(cb, options);

// 監視を開始
contents.forEach(content => {
    io.observe(content);
});


各セクションの.contentをターゲット要素として取得し、スクロールで表示されたタイミングで.animateを付与します。
要素に.animateが付与されたら、CSSで設定されたアニメーションが動く仕組みです。

コールバックで呼び出される関数がこちらです。
ターゲット要素を1つずつ処理します。ここで、アニメーションさせる.animateを付与しています。
※<ターゲット要素>.targetでターゲット要素を操作できます。

const cb = function(entries, observer) {
    entries.forEach(entry => {
        if(entry.isIntersecting) {
            entry.target.classList.add('animate');
            observer.unobserve(entry.target); //監視の終了
        }
    });
}

今回、一度アニメーションさせたらターゲット要素の監視を終了したかったのでobserver.unobserve(<ターゲット要素>)を指定しました。
これで監視が終了します。

また、スクロールの際、ターゲット要素が少し表示されてからアニメーションを動かしたかったので、オプションthresholdの値を0.3にしました。
これでターゲット要素が30%表示されてからイベントが発火します。それ以外のオプションはデフォルト値です。

// オプション
const options = {
    root: null,
    rootMargin: "0px",
    threshold: 0.3
}

observe()メソッドに指定できるターゲット要素は1つです。
そのため、ターゲット要素が複数ある場合は1つ1つ設定する必要があります。同じクラス名ならforを使うと記述が短くなります。

const contents = document.querySelectorAll(".content");

// 監視を開始
contents.forEach(content => {
    io.observe(content);
});

CSS

CSSでアニメーションの動きを設定します。
今回、.animateが付与された要素に対してアニメーションの動きを設定します。

/* 共通 */
html {
    height: auto;
}
.content {
    min-height: 100vh;
    position: relative;
}
.content .inner{
    width: 100%;
    box-sizing: border-box;
    padding: 10px;
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
}
/* 背景色 */
.content:nth-child(1) {
    background: pink;
}
.content:nth-child(2) {
    background:powderblue;
}
.content:nth-child(3) {
    background:wheat;
}
/* タイトル */
.ttl {
    opacity: 0;
    transform: translateY(-30%);
    transition: all 2s ease;
    margin: 0 auto;
    padding-top: 20px;
    margin-bottom: 20px;
    font-weight: bold;
    font-size: 1.6em;
    font-family: 'Patua One', cursive;
    text-align: center;
    letter-spacing: 0.03em;
    border-bottom: none;
}
.ttl::after {
    border-bottom: 1px solid #000;
    width: 20%;
    margin: auto;
    left: 0;
    right: 0;
}
.animate .ttl {
    opacity: 1;
    transform: translateY(0);
}
/* テキストエリア */
.txt {
    height: 30vw;
    padding: 20px;
    margin: 0 8px;
    background:rgba(212, 107, 107, 0.5);
    opacity: 0;
    transform: translateY(-20%);
    transition: opacity 1s ease 0.3s, transform 1s ease 0.3s;
}
.animate .txt {
    opacity: 1;
    transform: translateY(0);
}
/* 複数コンテンツ */
.fl-box {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    padding:0 8px 8px;
}
.fl-box > div {
    opacity: 0;
    transform: translateY(-20%);
    flex: 0 1 19%;
    height: 100px;
    background:rgba(95, 100, 151, 0.5);
    margin-top: 12px;
    transition: opacity 1s ease, transform 1s ease; 
}
.fl-box::after {
    content: "";
    display: block;
    flex: 0 1 19%;
}
.animate .fl-box > div {
    opacity: 1;
    transform: translateY(0);
}
.animate .fl-box > div:nth-child(1) {
    transition-delay: 0;
}
.animate .fl-box > div:nth-child(2) {
    transition-delay: 0.3s;
}
.animate .fl-box > div:nth-child(3) {
    transition-delay: 0.6s;
}
.animate .fl-box > div:nth-child(4) {
    transition-delay: 0.9s;
}
.animate .fl-box > div:nth-child(5) {
    transition-delay: 1.2s;
}
.animate .fl-box > div:nth-child(6) {
    transition-delay: 1.5s;
}
.animate .fl-box > div:nth-child(7) {
    transition-delay: 2s;
}
.animate .fl-box > div:nth-child(8) {
    transition-delay: 2.5s;
}
.animate .fl-box > div:nth-child(9) {
    transition-delay: 3s;
}
/* 左右エリア */
.lr-wrap {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    padding:0 8px 8px;
}
.lr-wrap > div {
    opacity: 0;
    margin-top: 12px;
    flex: 0 49%;
    height: 200px;
    background:rgb(148, 118, 96, 0.5);
    transform: translateX(-100%);
    transition: 
        opacity 1.5s cubic-bezier(0, 0, 0, 0.85) 0.3s,
        transform 1.5s cubic-bezier(0, 0, 0, 0.85) 0.3s;
}
.lr-wrap > div.left {
    transform: translateX(-100%);
    transition-delay: 0.5s;
}
.lr-wrap > div.right {
    transform: translateX(100%);
    transition-delay: 0.8s;
}
.animate .lr-wrap > div {
    opacity: 1;
    transform: translateY(0);
}

HTML

HTMLにポイントはなく、シンプルに要素を並べているだけです。

<div class="content_wrap">
    <section class="content">
        <div class="inner">
            <h2 class="ttl">FIRST TITLE</h2>
            <div class="txt">
                &nbsp;
            </div>
        </div>
    </section>
    <section class="content">
        <div class="inner">
            <h2 class="ttl">SECOND TITLE</h2>
            <div class="fl-box">
                <div>&nbsp;</div>
                <div>&nbsp;</div>
                <div>&nbsp;</div>
                <div>&nbsp;</div>
                <div>&nbsp;</div>
                <div>&nbsp;</div>
                <div>&nbsp;</div>
                <div>&nbsp;</div>
                <div>&nbsp;</div>
            </div>
        </div>
    </section>
    <section class="content">
        <div class="inner">
            <h2 class="ttl">THIRD TITLE</h2>
            <div class="lr-wrap">
                <div class="box left">&nbsp;</div>
                <div class="box right">&nbsp;</div>
                <div class="box right">&nbsp;</div>
                <div class="box left">&nbsp;</div>
            </div>
        </div>
    </section>
    
</div>

まとめ

今回、Intersection Observerについて調べました。基本的なことしか調べていませんが、CSSやオプションの組み合わせで多様な表現ができそうです!