コーポレートサイトなどで、実績や顧客数、満足度など数値でアピールできるコンテンツが横並びで構成され、ページをスクロールしてそれらが可視領域に到達したらカウントアップアニメーションで表示するカウンターを見たことがある方も多いのではないでしょうか。
今回はそんなカウンター要素を外部ライブラリなどを用いず自前の HTML、CSS、JavaScript だけで汎用性を考慮しつつなるべく簡単に表現できるよう作成してみます。
まずはデモをご覧ください。
See the Pen Count Up Numbers on Scrolling The Page by digistate (@digistate) on CodePen.
このカウンターを構成するにあたり、カウントアップの動作に関しては今回は以下の要件を満たすように実装していきましょう。
- 下方にスクロールして可視領域に到達した時点でカウントアップのアニメーションを開始する
- カウントアップアニメーションの時間をミリ秒( 1s = 1000ms )で指定可能にする
- 初期値(カウント開始の数値)と最終値(カウント終了の数値)は data 属性で HTML タグ内にセットし、それを JavaScript 側で利用する
- 数値をロケールに応じた区切り(日本の場合は3桁ごとでカンマ区切り)にする
- 初期値と最終値の差(カウントアップ終了時の値)は、浮動小数点数を含む場合でも対応する
- 各アイテムで割り出された数値(カウント増加量)に関係なく、指定されたアニメーションの時間で同時にカウントアップが終了(最終値に到達)するようにする
それでは、まず HTML について解説します。
HTML
今回のデモの HTML は、以下の構成となっています。
<div class="item-list"> <div class="item" data-from="0" data-to="256" data-duration="1500"> <i class="fa-solid fa-award"></i> <div class="counter"> <span class="number">0</span> </div> <h3 class="title">Awards</h3> </div> ... </div>
カウンター単体の各アイテムは、.item-list
というラッパー要素内に .item
セレクタがつけられた div
要素でまとめられて連なっています。
ポイントは、.item
に data 属性でカウント開始時(data-from
)と終了時(data-to
)の数値、そしてカウントアップにかかる時間(data-duration
)をミリ秒(1s = 1000ms)として指定し、カウントアップを実行する JavaScript 側で取得できるようにしておきます。
<div class="item" data-from="0" data-to="256" data-duration="1500">
上記の場合であれば、「0 から 256 までを 1500ミリ秒(1.5秒)かけてカウントアップする」という動作を意味します。
JavaScript 側から書き換えるカウンター要素は .counter
> .number
です。
<div class="counter"> <span class="number">0</span> </div>
このような構成の .item
要素(カウンター)を .item-list
の中に 1〜4 個程度でまとめておきます。
JavaScript
JavaScript では、スクロールによって.item
要素が可視領域(画面上)にすべて現れたら、各 data 属性の値を取得して指定された時間でカウントアップと同時にその値をカウンター要素のテキストにセットします。
スクロールで可視領域に到達したらカウントを開始する
スクロールでアニメーション(処理)を開始させるには、scroll イベントではなく Intersection Observer API を利用します。
Intersection Observer については以下にて詳しく解説しています。
以下のコードで画面(ビューポート)と対象要素の交差量を監視することでカウントアップ動作の開始を行っています。
// カウンター要素をすべて取得 const items = document.querySelectorAll( '.item' ); // コールバック関数(カウントアップ処理) const observeAction = ( entries ) => { entries.forEach( entry => { // 可視領域に到達したとき if ( entry.isIntersecting ) { // .is-visible セレクタがない場合のみ if ( ! entry.target.classList.contains( 'is-visible' ) ) { // ここにカウントアップ処理 // .is-visible セレクタを追加 entry.target.classList.add( 'is-visible' ); } } } } // 交差判定条件 const options = { threshold: 1, // カウンター要素がすべて画面に表示されたとき(= 1) } // 交差オブザーバーのインスタンスを生成 const obsever = new IntersectionObserver( observeAction, options ); // すべてのカウンター要素について items.forEach( target => { // 監視を開始 obsever.observe( target ); } );
交差状態か否かを確認するには、entry.isIntersecting
でチェックし、さらに is-visible
セレクタを挿入し、既に is-visible
セレクタがある場合は処理をスルーすることでカウントアップのアニメーションは1度だけ実行されるようにします。
data 属性の値を取得
.item
要素には、カウントアップの実行に必要なカウント開始時の値(data-from
)、カウント終了時の値(data-to
)、アニメーション時間(data-duration
)の3つの data 属性が存在しています。
このデータを取得します。
// カウント開始時の値(初期値: 0) const from = parseFloat( entry.target.dataset.from || 0); // カウント終了時の値(初期値: 0) const to = parseFloat( entry.target.dataset.to || 0); // アニメーション時間(ミリ秒/初期値: 1500) const duration = parseInt( entry.target.dataset.duration ) || 1500;
element.dataset
で取得される値は文字型として扱われるため、小数点以下を含む数値である場合も考慮して 定数 from
と to
はparseFloat
関数で浮動小数点値に変換しておきます。
カウントアップの実行
カウント開始値からカウント終了値まで、指定されたアニメーション時間で均一にカウントアップを行うアニメーションを表現する方法ために、表示されているディスプレイのリフレッシュレート(1秒あたりの描画更新回数)で描画(モーション)を行うrequestAnimationFrame
という関数を利用します。
requestAnimationFrameについて
一定間隔で処理を繰り返す setInterval
という関数もありますが、この関数は指定した間隔(最短10ミリ秒)で処理を繰り返すため、ブラウザの負荷が著しく増える可能性があり、アニメーションの動作がカクカクしたりフリーズする原因になりやすいため、特にアニメーション用の繰り返し処理にはrequestAnimationFrame
の利用が適しています。
また、リフレッシュレートは、ヘルツ(Hz)という単位で測定されますが、ディスプレイによってこの単位が異なるため、リフレッシュレートを基準に描画を更新してしまうと、閲覧されているディスプレイごとでアニメーション時間がずれてしまいます。
そのため、アニメーションの進捗の管理はアニメーション開始からの経過時間を基準にすることで、リフレッシュレートに関係なく等しく行います。
カウントアップ開始時のタイムスタンプの取得
まず事前にアニメーションの開始時のタイムスタンプを取得しておきます。
タイムスタンプの取得には、Date.now()
よりも精度が高く経過時間の計測に適している performance.now()
を利用します。
const startTime = performance.now();
カウント量の取得
さらに、カウントアップに必要な情報として、指定時間かけてカウントする実際の増加量(カウント開始時の値 – カウント終了時の値)も用意しておきます。
const increment = to - from;
小数点以下の桁数の取得
カウント量が浮動小数点数であった場合は、カウントアニメーション中も小数点以下の値も含めて描画させるため、まずその小数点以下の桁数を取得するための関数を用意します。
// 小数点以下の桁数の計算 const getDecimalPointLength =( n ) => { // n を一旦文字列に変換し、 ' . ' の位置で区切った配列から小数点以下が含まれる2番目([1])の配列アイテムの長さを取得 return ( String( n ).split( '.' )[1] || '' ).length; } // カウント量の小数点以下の桁数の取得 const deciamlPointLength = getDecimalPointLength( increment );
経過時間の取得
指定されたアニメーション時間(ミリ秒)まで処理を繰り返すために、アニメーション開始からの経過時間を取得しておきます。
const elapsed = performance.now() - startTime;
描画するカウンターの値の計算
指定したアニメーション時間( = 経過時間 ) でカウント終了時の値まで均一に数値が段階的に増えていくように、経過時間(elapsed
)を基にして requestAnimationFrame
によって毎フレーム更新されるカウンターの数値を計算します。
const countValue = ( from + ( ( elapsed / duration ) * increment ) ).toFixed( deciamlPointLength );
カウンターの値は、toFixed()
関数によってカウント量の小数点以下の桁数までを取得するようにします。
カウンターの更新・アニメーションの完了
あとは取得した countValue
を対象要素のテキストと入れ替えます。countValue
がカウント終了値( to
)以上になった場合は、そこでアニメーションは完了します。
if ( countValue >= to ) { // countValue が カウント終了値以上になったら終了 counterEle.innerText = to.toLocaleString(); } else { // カウンターを更新して次のアニメーションを実行 counterEle.innerText = parseFloat( countValue ).toLocaleString(); requestAnimationFrame( countUp ); }
まとめ
これらの処理をまとめると、JavaScript 全体のコードは以下のようになります。
const items = document.querySelectorAll( '.item' ); const isFloat = ( n ) => { return Number( n ) === n && n % 1 !== 0; } const getDecimalPointLength =( n ) => { return ( String( n ).split( '.' )[1] || '' ).length; } const observeAction = ( entries ) => { entries.forEach( entry => { if ( entry.isIntersecting ) { if ( ! entry.target.classList.contains( 'is-visible' ) ) { const counterEle = entry.target.querySelector( '.counter .number' ); const from = parseFloat( entry.target.dataset.from || 0 ); const to = parseFloat( entry.target.dataset.to || 0 ); const duration = parseInt( entry.target.dataset.duration ) || 1500; if ( !Number.isFinite( from ) || !Number.isFinite( to ) || from > to ) { return false; } const increment = to - from; const deciamlPointLength = getDecimalPointLength( increment ); const startTime = performance.now(); const countUp = ( timestamp ) => { const elapsed = performance.now() - startTime; const countValue = ( from + ( ( elapsed / duration ) * increment ) ).toFixed( deciamlPointLength ); if ( countValue >= to ) { counterEle.innerText = to.toLocaleString(); } else { counterEle.innerText = parseFloat( countValue ).toLocaleString(); requestAnimationFrame( countUp ); } } requestAnimationFrame( countUp ); entry.target.classList.add( 'is-visible' ); } } } ); } const options = { threshold: 1, } const obsever = new IntersectionObserver( observeAction, options ); items.forEach( target => { obsever.observe( target ); } );
Intersection Observer を利用することで、様々なスクロールアニメーションのアイディアの実装が楽になります。
このような仕組みを利用したカウントアップ要素を表示する WordPress のブロックは、今後「DigiPress Ex – Blocks」プラグインに追加予定です。
他のチュートリアルはまた次回以降順次紹介したいと思います。