今回は、縦にスクロールしていくと左右から交互に画像やテキストがアニメーションしながら現れる Web ページのサンプルを、要素の交差を監視を行う Intersection Observer という JavaScript の 交差オブザーバー API を利用して作っていきます。
例えば Web サイトで商品やメンバーの紹介を視覚的にアピールしたい場合などに利用できそうです。
まずは実際のサンプルをご覧ください。
See the Pen Scroll Fade-in Animation Sample by digistate (@digistate) on CodePen.
デモ1では、要素間の交差の有無のみを判定し、表示用のセレクタを対象要素に挿入することでフェードインしています。
See the Pen Scroll Fade-in Animation Sample #2 by digistate (@digistate) on CodePen.
デモ2では、画像については要素間の交差量(≒スクロール量)に応じて 動的にスタイルを変更することでアニメーション表示しています。
アイテムの構造
このサンプルの構造は、まず HTML でスクロールして可視領域に入った際に表示したい要素(サンプルでは画像とテキスト)を必要な数だけ同じタグ構成で羅列しておきます。
<figure class="person"> <img class="photo" src="画像URL"> <figcaption class="name"><span>テキスト</span></figcaption> </figure>
ここでは figure
要素を利用していますが、section
要素にしてテキストは見出しタグの構成でもいいと思います。
レイアウト
アイテムを左右交互に寄せる
今回のサンプルでは、スクロールでアニメーション表示させる各アイテム(.person
)は左右交互に順番に表示させるため、display: flex
の水平方向の寄せを行うプロパティ(justify-content
)を利用して、偶数番目(:nth-child(2n)
)のアイテムは右寄せ(justify-content: flex-end;
)にします。
.person{ position: relative; display: flex; ... // 偶数番目のアイテム &:nth-child(2n){ justify-content: flex-end; // 右に寄せる ... } }
アイテム内に表示している画像(.photo
)とテキスト(.name
)は同じ階層の要素ですが、テキストは絶対位置にして画像上にテキストが表示されるようにするため、偶数番目のアイテムのテキストは位置指定で右側に寄せるようにします。
.photo{ width: 45%; // 幅はお好みで max-width: 480px; height: auto; ... }
.name{ position: absolute; bottom: 2vw; // 上下の位置はお好みで left: 0; // 左に寄せる display: inline-block; ... }
.preson{ // 偶数番目のアイテム &:nth-child(2n){ .name{ right: 0; // 右に寄せる left: auto; } } ... }
これらをまとめると以下のイメージでレイアウトが作られます。
アニメーション用のスタイル
要素の交差を判定したアニメーション(デモ1の場合)は、スクロールして対象のアイテム要素が指定した可視領域に達した際に Intersection Observer によって表示用のセレクタ(.is-visible
)をアイテム要素に挿入することで実現します。
そのため、アイテム要素の初期状態はアニメーション前の位置やサイズ、不透明度に調整しておく必要があります。
CSS 変数(カスタムプロパティ)を利用し、初期状態はカスタムプロパティの初期値に指定しておきます。
アニメーション前のスタイル(初期状態)
アニメーションで表示するため、transition
でアニメーション時間も予め指定しておきます。
今回のサンプルでは、円が広がりながら画像が等倍に戻るアニメーションを想定し、一旦 clip-path
で円形にすべて切り抜き、transform
で 10% 拡大しておきます。
.photo{ ... clip-path: circle( var( --img-clip-rate, 0% ) at 50% 50% ); transform: scale( var( --img-scale-reset, 1.1 ) ); transition: clip-path 1.2s linear, transform 1.2s linear; // デモ1の場合のみ }
--img-clip-rate
, --img-scale-reset
にはアニメーション後の値が入ります。
テキストは、::before
疑似セレクタをテキストの背景に利用し、背景とテキストをそれぞれアニメーションで表示させます。
.name{ // テキストの背景 &::before{ content: ''; position: absolute; top:0; right:0; bottom:0; left:0; background-color: rgba( #000, .34 ); transform:scaleX( var( --name-bg-scale, 0% ) ); // 水平方向のスケールを 0 にする transform-origin: left center; // 移動の基準を左端にする transition: transform .8s linear; // 0.8秒かけてアニメーション } // テキスト(span で括る) span{ position: relative; display: block; clip-path: polygon( 0 0, var( --name-clip-increase, 0) 0, var( --name-clip-increase, 0 ) 100%, 0 100% ); // 左端に上下2点ずつ重ねた状態で切り抜き transform: var( --name-position-reset, translate3d( -40px, 0, 0 ) ); // 左方向に 40px 移動 transition: clip-path .8s linear, transform 1.2s ease-out; } }
左右交互からアニメーションさせるため、偶数番目(右側)のアイテム内の画像とテキストは逆の初期状態にしておきます。
.person{ ... // 偶数番目(右側) &:nth-child(2n){ .name{ left: auto; right: 0; // 右側に寄せる // テキストの背景 &::before{ transform-origin: right center; // 移動の基準を右端にする } // テキスト span{ clip-path: polygon( var( --name-clip-decrease, 100% ) 0, 100% 0, 100% 100%, var( --name-clip-decrease, 100% ) 100% ); // 右端に上下2点ずつ重ねた状態で切り抜き transform: var( --name-position-reset, translateX( 40px ) ); // 右向に 40px 移動 } } } ... }
アニメーション後のスタイル
アニメーション後のスタイルは CSS のカスタムプロパティ(変数) に与えておくだけです。
後述する IntersectionObserver
によって各アイテムが可視領域に達したときに挿入されるアニメーション開始用のトリガーである .is-visuble
セレクタ内にカスタムプロパティの値をセットします。
// デモ1の場合のみ .photo{ ... &.is-visible{ --img-clip-rate: 100%; // 画像が見えるよう切り抜き --img-scale-reset: 1; // 画像の拡大率を戻す } }
.name{ ... &.is-visible{ --name-clip-increase: 100%; // テキスト全体が見えるよう切り抜き(左側用) --name-clip-decrease: 0%; // テキスト全体が見えるよう切り抜き(右側用) --name-bg-scale: 1; // テキストの背景の水平方向の拡大率を戻す --name-position-reset: translate3d( 0, 0, 0 ); // テキストの背景の水平方向の移動を戻す } }
スクロールアニメーションの制御
IntersectionObserver
に交差の基準となる要素と指定した対象の要素が重なったと判断する条件を与え、スクロールをしてアイテムが可視領域の目的の位置に達した時点で、アイテム要素に .is-visible
セレクタを挿入して画像とテキストをアニメーションで表示させます。
デモ2の場合は、画像のみ要素間の交差量( ≒ スクロール量)に応じて動的にスタイルを変更することで実現しています。
const persons = document.querySelectorAll( '.person' ); const names = document.querySelectorAll( '.name' );
監視対象とする要素は、アイテム要素自身(.person
)と絶対位置を指定しているテキスト(.name
)の2種類です。
IntersectionObserver
は、第1引数に基準となるルート要素と指定した対象要素が交差したときに実行させたい処理(コールバック関数)を指定し、第2引数には交差したと判断する条件(オプション)を指定します。
// 監視対象 coonst target = document.querySelector( '#target' ); // 交差判定条件 const options = { root: document.querySelector( '#root' ), // 画面全体を基準にする場合は未指定(規定) rootMargin: '0px', // 判断基準となる root からの距離(margin) threshold: 0.5 // コールバックの実行を判定する対象要素の表示割合(0.0 - 1.0) } // コールバック関数 const callback = ( entries ) => { entries.forEach( ( entry ) => { if ( entry.isIntersecting ) { // 交差したとき console.dir( entry.intersectionRatio * 100 ); // 現在の対象要素の表示割合(0〜100%) } else { // 交差しなくなったとき } } ); } // 交差オブザーバーインスタンスの生成 const observer = new IntersectionObserver( callback, options ); // 監視を開始 obsever.observe( target );
交差判定条件(options)
IntersectionObserver
に渡すオプション(オブジェクト)には、以下の3項目を指定できます。
- root
-
要素の交差を判断するための基準となる要素を指定します。
未指定の場合は、ビューポート(画面全体)が基準となり、画面内に対象とする要素が現れた(交差した)時点で「交差した」とみなされ、スクロールして対象要素が画面上から消えた時点で「交差しなくなった」とみなされます。
root: document.querySelector( '#target' ),
- rootMargin
-
基準となる要素(root)の外側、または内側にどの程度の距離へ対象要素が到達したら「交差した」(「交差しなくなった」)とみなすか、その判断基準となる距離を “上 右 下 左” の順で指定します (交差を判定する「root からの距離(margin)」)。
例えば、縦スクロールで画面(root)に表示される 80px 下の位置からコールバック関数を実行したい場合は以下のようになります。
rootMargin: "0px 0px 80px 0px",
画面(root)の中心に対象要素が到達したときにコールバック関数を実行したい場合は以下のようになります(対になる場合は省略形が使えます)。
rootMargin: "-50% 0px",
距離を指定しない場合でも「0px」や「0%」など、0 に単位を付けて指定する必要があります。
rootMargin の図解 - threshold
-
対象要素が基準となる要素(root)に現れ、どの程度見えてる状態に到達したらコールバック関数を実行するかを決める閾値を数値(0.0〜1.0)で指定します。
コールバック関数を実行させたいポイント(表示割合)が複数ある場合は、閾値の配列で指定します。
例えば、対象要素が画面の一番下に到達した時点から 25% ずつ移動するたびにコールバック関数を実行させる場合は以下のようにします。
threshold: [0, 0.25, 0.5, 0.75, 1],
コールバックが呼び出されたときの対象要素の交差量は
intersectionRatio
で取得できるので、スクロール量に応じて段階的に滑らかなアニメーションを施したい場合は、 0〜1(0〜100%)の範囲で閾値(検知する表示割合)の数を意図する動作になる程度まで段階的に増やします。閾値の配列を柔軟に生成するには、以下のような関数を利用すると 0〜1 の範囲を指定したステップ数で分割した配列として取得できます。
閾値リストを生成する関数初期値: 20分割const buildThresholdList = ( numSteps = 20 ) => { const thresholds = []; for ( let i = 1; i <= numSteps; ++i ) { const ratio = i / numSteps; thresholds.push(ratio); } return thresholds; }
例えば
threshold
に 0〜1 の範囲を 50分割した配列を渡したいときは、options
パラメータは以下のようにします。const options = { threshold: buildThresholdList( 50 ) }
デモ1の交差判定条件
デモ1の場合は、基準とするルート要素(画面全体)の下から内側へ30%の距離を対象要素( ≒ 画像)が通過したら表示用のセレクタ(.is-visible
)を挿入し、上から50%内側通過したらセレクタを削除します。
テキストについても同様に下から20%を超えたら表示(セレクタを挿入)し、画面上端から40%下の位置より上にテキストがスクロールされたら再び非表示に(セレクタを削除)するという条件を指定します。
// アイテム(≒画像)の表示条件 const optionsPersons = { rootMargin:"-50% 0px -30% 0%" } // テキストの表示条件 const optionsNames = { rootMargin:"-40% 0px -20% 0px" }
デモ2の交差判定条件
デモ2の場合は、画像のみルート要素との交差量(intersectionRatio
)に応じて clip-path
の値をスクロールと連動するように動的に書き換えて切り抜きサイズを変更しています。
// アイテム(≒画像)の表示条件 const optionsPersons = { threshold: buildThresholdList( 60 ), // buildThresholdList 関数で 0-1 までを60等分 }
コールバック関数(アニメーションの実行)
デモ1のコールバック関数
IntersectionObserver
で呼び出されるコールバック関数では、交差判定条件を元にして交差したと判断された場合は .is-visible
セレクタを挿入し、条件から外れた場合はセレクタを削除するだけのシンプルな内容です。
const observeAction = ( entries ) => { entries.forEach( entry => { if ( entry.isIntersecting ) { entry.target.classList.add( 'is-visible' ); } else { entry.target.classList.remove( 'is-visible' ); } } ); }
このコールバック関数によって、上下にスクロールすることで対象要素に .is-visible
セレクタの挿入または削除が行われ、.is-visible
セレクタに紐付けられた CSS によって対象要素がアニメーションで表示または非表示になります。
デモ2のコールバック関数
デモ2の場合は、画像のみ交差量に応じてスタイルを書き換えるため、class で画像とテキストで処理を分け、画像の場合は画像の50%が可視領域に到達したら残りの50%の交差量に応じてスタイルを書き換えます。
const observeAction = ( entries ) => { entries.forEach( entry => { // 画像用 if ( entry.target.classList.contains( 'person' ) ) { if ( entry.isIntersecting ) { // 四捨五入し百分率に変換 const ratio = Math.round( entry.intersectionRatio * 100 ); // 交差量が50%を超えたら if ( ratio >= 50 ) { // style属性にカスタムプロパティで clip-path と scale へ交差量に応じた値を代入 entry.target.setAttribute( 'style', `--img-clip-rate:${ ratio - 50 }%; --img-scale-reset:${ 2 - ratio / 100 }` ); } } } // テキスト用 if ( entry.target.classList.contains( 'name' ) ) { if ( entry.isIntersecting ) { entry.target.classList.add( 'is-visible' ); } else { entry.target.classList.remove( 'is-visible' ); } } } ); }
交差の監視(アニメーションの実行)
スクロールアニメーションを行うには、先述のコールバック関数(アニメーションのための対象要素への操作)と交差判定条件(options)を元に Intersection Observer のインスタンスを生成し、監視対象の要素を渡して監視を開始させます。
// Intersection Observer のインスタンスを生成 const obsever = new IntersectionObserver( observeAction, options ); // 指定した要素(target)について監視を開始 obsever.observe( target );
ただし、今回のデモのようにスクロールでアニメーション表示させたい要素が複数ある場合は、forEach
などを利用して各要素ごとに監視を開始します。
// 監視対象の要素をすべて取得 const targets = document.querySelectorAll( '.target' ); // Intersection Observer のインスタンスを生成 const obsever = new IntersectionObserver( observeAction, options ); targets.forEach( target => { // 指定した要素(target)について監視を開始 obsever.observe( target ); } );
今回のデモでは2種類の要素(画像、テキスト)に分けて、異なる交差判定条件で監視を行うため、さらに以下のようにまとめます。
// 画像の交差判定条件 const optionsPersons = { rootMargin:"-50% 0px -30% 0px" // デモ1の場合 // threshold: buildThresholdList( 60 ), デモ2の場合 } // テキストの交差判定条件 const optionsNames = { rootMargin:"-40% 0px -20% 0px" } // 交差監視を開始する処理 const startObserve = ( targets, options ) => { // Intersection Observer のインスタンスを生成 const obsever = new IntersectionObserver( observeAction, options ); // 各対象要素ごとで監視を開始 targets.forEach( target => { obsever.observe( target ); } ); } // 画像の交差監視を指示 startObserve( persons, optionsPersons ); // テキストの交差監視を指示 startObserve( names, optionsNames );
一昔のようにスクロールイベントをせずに Intersection Observer を利用することで、スクロールするたびにイベントが発生することもなく、スクロール量に応じてアニメーションさせたい場合でもとてもシンプルなコードで簡潔にわかりやすくまとめることができます。
アイディア次第で色々なインタラクティブなコンテンツが作れるので、ぜひお試しください。