
今回は、Webページ上にサービスレベルやスキル、実績、状況などあらゆる物事についての度合いや進捗状況を表すための汎用性を考慮した円形グラフ(プログレスバー)を HTML(svg)、CSS、少量の JavaScript で表示するサンプルをご紹介します。
スクロールしてプログレスバーの要素が可視領域に入ったら、指定割合までプログレスバーとパーセンテージのカウンター数値が指定した秒数でアニメーションしながら表示されます。
まずは今回作成する円形プログレスバーのデモをご覧ください。
このプログレスバーを構成するにあたり、今回は以下の要件を定義しました。
- スクロールして可視領域に到達したら、進捗アニメーションを実行する。
- パーセンテージ、バーの色・太さ、アニメーション時間、進捗を開始するポイント(上、左、下、右)は、data 属性で個別に指定可能にする。
- プログレスバーのアニメーションと同時に、パーセンテージのカウントアップもグラフの中央に表示する。
- パーセンテージが浮動小数点数の場合は、小数点以下の桁数も含めてカウントアップを表示する。
- アニメーション(繰り返し処理)には負荷の高い
setInterval
ではなく、requestAnimationFrame
メソッドで処理する。
HTML
今回のデモでは、1〜4までの範囲で円形のプログレスバーを横並びにする想定をし、全体を括るラッパー要素内(.progress-container
)に、プログレスバー1つ分のコンテンツ(グラフ、パーセンテージ、テキスト)を包括する要素(.progress-item
)が1〜4つ存在しています。
- <div class="progress-container">
- <div class="progress-item">
- <div class="progress-item__inner">
- <svg class="progress-svg">
- ...
- </svg>
- <div class="progress-text">...</div>
- </div>
- <div class="progress-title">...</div>
- </div>
- <div class="progress-item">
- ...
- </div>
- ...
- </div>
円形のプログレスバーは、HTML で図形を描画できる svg
要素の circle
を利用します。
- <svg class="progress-svg" viewBox="0 0 100 100">
- <circle class="progress-background" cx="50" cy="50" r="45"></circle>
- <circle class="progress-bar" cx="50" cy="50" r="45"></circle>
- </svg>
svg
要素が円を描く全体のキャンバスとなり、その中の最初の circle
(.progress-background) はプログレスバーの背景用の円弧となり、2番目の circle
(.progress-bar
) はアニメーションで進捗を表現する円弧となります。
この svg
要素の構造についてもう少し詳しくみていきましょう。
- viewBox (svg)
-
svg
要素にあるviewBox="0 0 100 100"
とは、“x座標位置, y座標位置, 幅(width), 高さ(height)” の順にまとめて定義したもので、座標は左上を原点(0, 0)とした位置からの x, y の距離を表し、幅と高さは描画エリア(ビューポート)のサイズを表しています。今回、図形のサイズは親要素のサイズに合わせて可変するので、それに合わせて
svg
要素自体も正方形のエリアとして常に伸縮するよう、サイズを固定しないようwidth
,height
値は与えずにviewBox
の描画エリア(100 100) だけ与えています。 - cx, cy (circle)
-
circle
要素に与えられているcx
は円の中心の x 座標を表し、cy
は円の中心の y 座標をそれぞれ表しています。
つまり、 svg 要素のviewBox="0 0 100 100"
で設定された正方形の描画エリアの中心が円の中心となっています。 - r (circle)
-
ciecle
要素に与えらているr
は円の半径を表しています。100 x 100 の正方形のエリアに (x: 50, y: 50) を中心としてビューポートいっぱいに円を描くので、
r = 50
としたいところですが、SVG では、図形の端点が描画エリアの境界上に位置する場合、その図形は完全に描画されず、一部が描画エリアの外側にはみ出します。
そのため、円が描画エリア内に収まるようにするために、r = 45
としています。
data 属性
プログレスバーのデザインや進捗、アニメーションに関するアイテムごとに異なる変動要素は、プログレスバー単体のアイテム要素(.progress-item
)に data 属性で指定おくことで、後述の JavaScript でそのデータを受け取って処理できるようにしておきます。
- <div class="progress-item" data-percent="88" data-duration="3200" data-stroke-width="10" data-stroke-color="#adff00" data-start-position="right">
- ...
- </div>
- data-percent
-
このプログレスバーのパーセンテージを 0 〜 100 で指定します。
- data-duration
-
プログレスバーの進捗アニメーションの時間をミリ秒(1秒 = 1000ミリ秒)の数値で指定します。
- data-stroke-width
-
このプログレスバーの太さをピクセル値として数値で指定します。
- data-stroke-color
-
このプログレスバーの進捗アニメーションで表示されるバーのカラーを指定します。
- data-start-position
-
このプログレスバーの進捗アニメーションを開始する基点となる位置(上: top、右: right、下: bottom、左: left)を指定します。
CSS(カスタムプロパティ)
CSS については、先述の data 属性と連動するプログレスバーの表示やアニメーションに関わる部分のみ解説します。
- .progress-svg {
- circle{
- stroke-width: var(--stroke-width, 8);
- }
- }
- .progress-bar {
- // プログレスバーのカラー
- stroke: var(--stroke-color, #00ccff);
- // 円弧の破線の開始位置
- stroke-dashoffset:var(--stroke-dashoffset , 283);
- // プログレスバーのアニメーションを開始する位置(回転率)
- transform: rotate( var( --start-rotate, -90deg ) );
- // プログレスバーのアニメーション時間
- transition: stroke-dashoffset var(--duration, 1.5s) ease-out;
- }
- –stroke-width
-
circle
要素で描く円弧の太さを指定します。data-stroke-width
属性で値を受け取り、--stroke-width
にセットします。 - –stroke-color
-
stroke
プロパティでcircle
要素のストロークカラーが反映されます。data-stroke-color
属性から JavaScript でカラーコードを受け取り、--stroke-color
というカスタムプロパティにセットすることでバーのカラーを個別に設定できます。 - –stroke-dashoffset
-
stroke-dasharray
プロパティでは、SVG で破線を描く際の間隔を指定できます。stroke-dashoffset
プロパティでは、破線を描く際の開始位置を指定できます。円周の長さは、2πr で計算できるので、今回の SVG circle で描く円の場合、半径を 45 としているので「2 x π x 45 = 282.7433388230814」が円周の長さとなります。
従って、プログレスバーの背景(
.progress-background
)とアニメーション用(.progress-bar
)の両方のcircle
要素のstroke-dasharray
に円周の値を四捨五入した 283 を指定し、.progress-bar
のcircle
要素にはさらにstroke-dashoffset
プロパティに、円周の長さを 100 として指定されたパーセンテージ分の残りのサイズを指定すれば、正円状態のストロークを指定されたパーセンテージ分だけ空いた状態にできます。つまり、
stroke-dashoffset
が 283 のときのプログレスバー(ストローク)は 0% の状態になり、0 に近づくにつれてプログレスバーの進捗が進む状態になります。 - –start-rotate
-
プログレスバーのストロークが始まる位置は、
transfrom
プロパティのrotate
で変更(回転)します。回転率と位置の関係- 0deg: ストロークは右から開始します。
- 90deg: ストロークは下から開始します。
- 180deg: ストロークは左から開始します。
- 270deg: ストロークは上から開始します。
この回転率を
data-start-position
属性の値(top, right, bottom, left)から判定し、--start-rotate
にセットします。 - –duration
-
data-duration
属性の値からアニメーション時間をミリ秒として受け取り、--duration
にセットします。
JavaScript
今回のプログレスバーでは、スクロールして画面上に要素が一定割合現れたときにはじめて進捗アニメーションを開始させるために、Intersection Observer を利用します。
Intersection Observer の詳細については以下をご覧ください。
交差オブザーバー(IntersectionObserver)の利用
まず、プログレスバーのアイテム要素(.progress-item
)をすべて取得し、それらについて交差の監視を開始しておきます。
ここでは、options
にてスクロールしてアイテム要素がビューポートの40%以上交差したら「交差した」とみなす条件にしていますが、好みで閾値(threshold
)に変更しても構いません。
- // プログレスバー要素を取得
- const progressItems = document.querySelectorAll( '.progress-item' );
- // 交差オブザーバーのコールバック
- const observeAction = ( entries ) => {
- entries.forEach( entry => {
- // 交差した場合
- if ( entry.isIntersecting ){
- // ここにアニメーション用の処理
- }
- } );
- }
- // 交差判定基準(画面下から40%交差)
- const options = {
- rootMargin:"0px 0px -40% 0px"
- }
- // 交差オブザーバーの生成
- const obsever = new IntersectionObserver( observeAction, options );
- progressItems.forEach( target => {
- // 監視開始
- obsever.observe( target );
- } );
dataset のデータを取得
進捗アニメーションに必要なパーセンテージやストロークのカラーなど、アイテムごとに指定されている dataset
のデータを取得します。
- // パーセンテージ
- const percent = parseFloat( entry.target.dataset.percent );
- // アニメーション時間
- const duration = parseInt( entry.target.dataset.duration ) || 1500;
- // プログレスバーのカラー
- const strokeColor = entry.target.dataset.strokeColor;
- // プログレスバーの太さ
- const strokeWidth = entry.target.dataset.strokeWidth;
表示する円弧の長さを計算
パーセンテージを元にしたプログレスバーに表示する円弧を描くためのデータを求めるには、パーセンテージの他に、circle
要素の半径( r
)が必要になります。
この半径を取得して円周の長さを計算し、これを 100 (%)として指定されたパーセンテージの残りの分(長さ)をstroke-dashoffset
に代入することで円弧が指定された割合のみ表示される状態にします。
円周の長さ(circumference) = 2 x π x r
求める円周の長さ = circumference – ( circumference x percent / 100 )
- // 進捗部分の circle 要素(.progress-bar)を取得
- const eleProgressBar = entry.target.querySelector( '.progress-bar' );
- // circle 要素の r 属性の値(半径)を取得
- const radius = eleProgressBar.getAttribute( 'r' );
- // 円周の長さを求める
- const circumference = 2 * Math.PI * radius;
- // 円周からパーセンテージ分の長さを引いた長さ = stroke-dashoffset に渡す値
- const strokeDashOffset = Math.round( circumference - ( circumference * percent ) / 100 );
円弧を描く開始位置の判定
円弧の描画を開始する位置は、data-start-position
属性からキーワード(top, right, bottom, left) で取得し、オブジェクトリテラルという記法でそれぞれのキーに合った rotate
の回転率をマッチングさせます。
- const startPosition = {
- 'right': '0deg', // 右から
- 'bottom': '90deg', // 下から
- 'left': '180deg', // 左から
- 'top': '-90deg' // 上から
- }[ entry.target.dataset.startPosition ] || '-90deg';
CSS カスタムプロパティに割り当て
取得、計算した各データをアイテム要素(.progress-item
)の CSS のカスタムプロパティにセットします。
- entry.target.style.cssText = `
- --duration: ${ duration }ms;
- --start-rotate: ${ startPosition };
- --stroke-dashoffset: ${ strokeDashOffset };
- --stroke-color: ${ strokeColor };
- --stroke-width: ${ strokeWidth };
- `;
進捗バーのアニメーションは、この CSS が代入されると CSS の transition
によって実行されます。
パーセンテージのカウントアップ
円の中心に表示されるパーセンテージのカウントアップは、requestAnimationFrame
を利用して カウントアップ開始時間から経過時間を計測し、アニメーション時間で割った値に対するパーセンテージ分の数値をカウンター要素のインナーテキストとして、パーセンテージの値になるまで繰り返し代入します。
現在のカウント値 = ( 経過時間 / アニメーション時間 ) x パーセンテージ
requestAnimationFrame
を利用した指定時間までの繰り返し処理について以下に詳しく解説しています。
カウントアップ全体の処理の流れは以下のようになります。
- // カウント用
- let countValue = 0;
- // パーセンテージの小数点以下の桁数(長さ)を取得
- const deciamlPointLength = ( String( percent ).split('.')[1] || '' ).length;
- // アニメーション開始時のタイムスタンプ
- const startTime = performance.now();
- // カウントアップ処理
- const countUp = timestamp => {
- // 経過時間の取得
- const elapsed = timestamp - startTime;
- // 現在のカウント
- countValue = ( elapsed / duration ) * percent;
- // 現在のカウント値をパーセンテージの小数点以下の桁数と同じ桁数まで表示
- eleProgressValue.innerText = countValue.toFixed( deciamlPointLength );
- if ( elapsed < duration ) {
- // 経過時間 < アニメーション時間の場合は繰り返し
- requestAnimationFrame( countUp );
- } else {
- // 経過時間がアニメーション時間を超えたら終了
- eleProgressValue.innerText = percent;
- }
- }
- // カウントアップの開始
- requestAnimationFrame( countUp );
全体のコード
ここまでの各処理をまとめると以下のコードになります。
- const progressItems = document.querySelectorAll( '.progress-item' );
- const observeAction = ( entries ) => {
- entries.forEach( entry => {
- if ( !entry.isIntersecting || entry.target.classList.contains( 'is-visible' ) ) {
- return;
- }
- const percent = parseFloat( entry.target.dataset.percent );
- if ( !Number.isFinite( percent ) ) {
- return false;
- }
- const duration = parseInt( entry.target.dataset.duration ) || 1500;
- const eleProgressBar = entry.target.querySelector( '.progress-bar' );
- const eleProgressValue = entry.target.querySelector( '.progress-value' );
- const radius = eleProgressBar.getAttribute( 'r' );
- const circumference = 2 * Math.PI * radius;
- const strokeDashOffset = Math.round( circumference - ( circumference * percent ) / 100 );
- const startPosition = {
- 'right': '0deg',
- 'bottom': '90deg',
- 'left': '180deg',
- 'default': '-90deg'
- }[ entry.target.dataset.startPosition ] || '-90deg';
- const deciamlPointLength = ( String( percent ).split('.')[1] || '' ).length;
- const startTime = performance.now();
- let countValue = 0;
- const countUp = timestamp => {
- const elapsed = timestamp - startTime;
- countValue = ( elapsed / duration ) * percent;
- eleProgressValue.innerText = countValue.toFixed( deciamlPointLength );
- if ( elapsed < duration ) {
- requestAnimationFrame( countUp );
- } else {
- eleProgressValue.innerText = percent;
- }
- }
- entry.target.style.cssText = `
- --duration: ${ duration }ms;
- --start-rotate: ${ startPosition };
- --stroke-dashoffset: ${ strokeDashOffset };
- --stroke-color: ${ entry.target.dataset.strokeColor };
- --stroke-width: ${ entry.target.dataset.strokeWidth };
- `;
- requestAnimationFrame( countUp );
- entry.target.classList.add( 'is-visible' );
- } );
- }
- const options = {
- rootMargin:"0px 0px -40% 0px"
- }
- const obsever = new IntersectionObserver( observeAction, options );
- progressItems.forEach( target => {
- obsever.observe( target );
- } );
今回は、IntersectionObserver でスクロールして要素が表示されたらアニメーションを実行するためにビューポートとプログレスバー要素の交差を監視し、パーセンテージのカウントアップアニメーションは requestAnimationFrame でモニターのリフレッシュレート(Hz)を基準にしてループ処理を実行するサンプルをご紹介しました。
今回の円形プログレスバーに関しても、DigiPress テーマ用ブロックエディタープラグイン「DigiPress Ex – Blocks」のカスタムブロックとして今後追加する予定です。