requestAnimationFrame の使い方

以下は requestAnimationFrame() の基本的な使い方の解説です。setTimeout() との違いや経過時間の取得、FPS の取得、経過時間や進捗度によるアニメーション、イージングの適用方法、他のアニメーションとの連携方法などについて。スムーススクロールにイージングを適用する方法を追加しました。

作成日:2022年4月18日

関連ページ:

requestAnimationFrame()

requestAnimationFrame() はブラウザの描画のタイミングに合わせて指定したコールバック関数を実行してくれる Window インターフェイスのメソッドです。

このメソッドを呼び出すと、再描画するのに十分なリソースがブラウザにある場合はいつでも、指定したコールバック関数が実行されます。

言い換えると、このメソッドを呼び出すことはブラウザに「このタスク(コールバック関数)をキューに入れ、再描画の準備ができたら実行してください」と伝えることになります。

以下を記述するとブラウザが再描画可能なタイミングで(ほぼ即座に)「OK」とコンソールに出力されます。

requestAnimationFrame(() => {
  //ブラウザが再描画可能なタイミングで以下が実行されます
  console.log('OK!');
}); 

上記は以下のように書き換えることができます。

//コールバック関数 
const callback = () => {
  console.log('OK!');
}

//ブラウザが再描画可能なタイミングでコールバック関数を実行 
requestAnimationFrame(callback); 

但し、上記の場合、呼び出しは1回だけ実行されます。アニメーションなどの処理を継続的に要求するためには、ある種のループを作成して繰り返し処理をする必要があります。

基本的な使い方は、requestAnimationFrame() に指定するコールバック関数を定義して、そのコールバック関数の中で requestAnimationFrame() を使って自身を呼び出すことで、ブラウザの描画のタイミングに合わせてコールバック関数を繰り返し実行(ループ)するようにします。

let count = 0;  
  
//コールバック関数の定義
const countUp = () => {
  count++;
  console.log(count);
  
  //コールバック関数を繰り返す条件(必要に応じて設定)
  if(count < 60) {
    //requestAnimationFrame() を使って自身(コールバック関数)を呼び出す(繰り返し)
    requestAnimationFrame(countUp);
  }
}  

//コールバック関数を実行
countUp();

多くのブラウザーでは1秒間に60回描画されるので、上記の場合、コンソールに 1 2 3 … 60 と約1秒かけて出力されます。

コールバック関数の呼び出しは、基本的には毎秒60回(60 FPS)ですが、多くのブラウザーではディスプレイのリフレッシュレートに合わせて行われます。

60 FPS の場合、1000ms ÷ 60 = 16.6666… なので約16.7msに1回指定したコールバック関数が実行されることになりますが、必ずしもこれを保証するものではありません。

また、requestAnimationFrame() の特徴としては CSS アニメーションや Web Animation API ではできない DOM の属性値を requestAnimationFrame() を使えばアニメーション化することができます。

構文

以下が window.requestAnimationFrame() メソッドの構文です(window. は省略可能)。

var requestID = window.requestAnimationFrame(callback);

引数(callback)

引数には次の再描画のタイミングで呼び出すコールバック関数を指定します。

コールバック関数は requestAnimationFrame() がコールバック関数の呼び出しを開始した時点の時刻を示すタイムスタンプ(DOMHighResTimeStamp )を引数に受け取ります(この値は performance.now() から返された時刻になります)。

戻り値(requestID)

リクエスト ID を返します。この値は long 型のゼロではない整数値で、window.cancelAnimationFrame() に渡すことで、コールバック関数の更新を中止できます。

以下は requestAnimationFrame() メソッドを使ったアニメーションの例です。

コールバック関数の定義では、その中で requestAnimationFrame() を使って自身を呼び出すことでその関数を繰り返し実行するようにし、必要に応じて繰り返しの条件を設定します。

以下のコールバック関数 transX では、x の値が200未満の場合は x++で x の値を1増加させて、translateX() で1pxずつX軸方向へ移動し、requestAnimationFrame() に自身(transX)を指定することで処理を繰り返します。

そして x の値が200になると条件に合致しないので繰り返しが終了します。

60 FPS の場合、約16.7msに1回指定したコールバック関数が実行されるので、約3秒(約16.7x200=3340ms)で移動のアニメーションが完了します。

//x 軸方向に移動する距離
let x = 0;
//アニメーション対象の要素
const elem = document.querySelector('.elem');
  
//コールバック関数の定義
const transX = () => {
  //繰り返しの条件(x の値が200未満の場合、requestAnimationFrame()を呼び出す)
  if(x < 200) { 
    //値を1増加
    x++;
    //要素を x の値の分だけ X 軸方向に移動
    elem.style.transform = `translateX(${x}px)`; 
    //requestAnimationFrame() に自身を指定して呼び出して繰り返し
    requestAnimationFrame(transX);
  }
}
//コールバック関数を実行
transX();

setTimeout() との違い

setTimeout() を使ってもほぼ同様のことができますが、setTimeout() ではブラウザーの再描画する準備が整っているかどうかに関係なく指定した時間が経過すると必ず実行されてしまいます。

そのため、requestAnimationFrame() を使った方がリフレッシュレートに沿った滑らかなアニメーションが可能になります。

以下は前述の例を setTimeout() で書き換えた例です。

//x 軸方向に移動する距離
let x = 0;    
//アニメーション対象の要素
const elem = document.querySelector('.elem'); 

//コールバック関数の定義
const transX = () => {
  if(x < 200) {
    x++;
    elem.style.transform = `translateX(${x}px)`;
    //setTimeout() で 1000/60 秒後に自身を呼び出して繰り返し
    setTimeout( transX, 1000/60);
  }
}
//コールバック関数を実行 
transX();

以下は Play ボタンをクリックすると requestAnimationFrame() と setTimeout() を使った2つのアニメーションを実行する例です。span 要素には移動する距離を表す値を出力します。

HTML
<div class="elem1">RAF <span>0</span></div>
<div class="elem2">ST <span>0</span></div>
<button type="button" id="play">Play </button>
CSS
.elem1 {
  background-color: red;
}
.elem2{
  background-color: blue;
}
.elem1, .elem2 {
  width: 100px;
  height: 50px;
  color: #fff;
  line-height: 50px;
  text-align: center;
}

要素のアニメーションは前述の例と同じですが、移動する距離の値を出力するようにしています。また、ボタンをクリックしたらアニメーションが完了するまで、クリックできないようにボタンの要素に disabled 属性を設定しています。

let x1 = 0; //x 軸方向に移動する距離
const elem1 = document.querySelector('.elem1'); //対象の要素
const elem1Span = document.querySelector('.elem1 span');
  
let x2 = 0; //x 軸方向に移動する距離
const elem2 = document.querySelector('.elem2'); //対象の要素
const elem2Span = document.querySelector('.elem2 span');
  
//play ボタン
const play =  document.getElementById('play');
 
//requestAnimationFrame() を使った関数の定義
const transX1 = () => {
  if(x1 < 200) {
    x1++;
    elem1.style.transform = `translateX(${x1}px)`;
    //span 要素に x1 の値を出力
    elem1Span.textContent = x1;
    //requestAnimationFrame() で繰り返し
    requestAnimationFrame(transX1);
  }
  if(x1 === 200 && play.hasAttribute('disabled')) {
    //Play ボタンの要素の disabled 属性を削除
    play.removeAttribute('disabled');
  }
}

//setTimeout() を使った関数の定義  
const transX2 = () => {
  if(x2 < 200) { 
    x2++;
    elem2.style.transform = `translateX(${x2}px)`;
    elem2Span.textContent = x2;
    //setTimeout() で繰り返し
    setTimeout( transX2, 1000 / 60 );
  }
  if(x2 === 200 && play.hasAttribute('disabled')) {
    play.removeAttribute('disabled');
  }
}
  
//play ボタンにクリックイベントを設定  
play.addEventListener('click', (e)=> {
  //要素の位置を初期化
  elem1.style.transform = 'translateX(0px)';
  elem2.style.transform = 'translateX(0px)';
  //移動する距離の値を初期化
  x1 = 0;
  x2 = 0;
  //関数を実行
  transX1();
  transX2();
  //play ボタンの要素に disabled 属性を設定
  e.currentTarget.setAttribute('disabled', true);
});

RAF 0
ST 0

経過時間の取得

requestAnimationFrame() に指定するコールバック関数は、コールバック関数として呼び出された時点の時刻を示すタイムスタンプを引数に受け取ります。

最初にコールバック関数が呼び出された時点の引数(タイムスタンプ)と以降に呼び出されたコールバック関数の引数の差分から経過時間を取得することができます。

以下は前述の例と同じアニメーションですが、移動する距離の代わりに経過時間を出力する例です。

変数 start は最初は未定義(undefined)なので、start には初回にコールバック関数が呼び出された時点のタイムスタンプ、つまり開始時刻を示すタイムスタンプが代入されます。

2回目以降、コールバック関数が呼び出される際にはすでに start には開始時刻が入っているので、その時点でのタイムスタンプと開始時刻(start)の差分で経過時間(elapsed)を取得することができます。

elapsed = timestamp - start

この方法の場合、start が最初は undefined であることにより開始時刻を取得・設定しています。

//アニメーションの開始時刻と経過時間を格納する変数
let start, elapsed;
  
let x = 0; //移動する距離
const elem = document.querySelector('.elem');
const elemSpan = document.querySelector('.elem span');
const play =  document.getElementById('play');

//引数にタイムスタンプ(timestamp)を受け取る
const transX = (timestamp) => {
  if (start === undefined) {
    //初回実行時の timestamp を start に代入
    start = timestamp;
  }
  //経過時間の算出
  elapsed = timestamp - start;

  if(x < 200) {
    x++;
    elem.style.transform = `translateX(${x}px)`;
    //経過時間を出力(小数点以下は切り捨てて出力)
    elemSpan.textContent = Math.floor(elapsed);
    requestAnimationFrame(transX);
  }
  if(x === 200 && play.hasAttribute('disabled')) {
    play.removeAttribute('disabled');
  }
}

play.addEventListener('click', (e)=> {
  elem.style.transform = 'translateX(0px)';
  x = 0;
  //経過時間の初期化
  elapsed = 0;
  //開始時刻の初期化
  start = undefined;
  transX();
  e.currentTarget.setAttribute('disabled', true);
});

※ 正確には最初の transX() の実行時(37行目のクリックイベントでの呼び出しの際)には引数はありませんが、関数内部でコールバック関数として、つまり requestAnimationFrame(transX) として呼び出された際(23行目)にはタイムスタンプが渡されます。

<div class="elem"><span>0</span> ms</div>
<button type="button" id="play">Play </button>
0 ms
FPS の取得

FPS(Frame Per Second)は1秒間当たりのフレームの表示回数(描画回数)ですが、以下のように1秒間に何回コールバック関数が呼び出されたかを取得して検出することができます。

この例では、1回のフレームの表示時間も表示しています(以下の frame time: に出力)。

<p>FPS:<span id="fpsOut"></span></p>
<p>frame time:<span id="ftOut"></span></p><!--1回のフレームの表示時間-->

1回のフレームの表示時間は小数点以下4桁で切り捨てて表示しています。

また、FPS は最初の1秒間は有効な値がないので、最初の1秒間は空文字を出力しています。

//FPS の値を入れる変数の初期化
let fps;
//フレームの表示回数(コールバック関数が呼び出された回数)
let frameCount = 0;
//開始時刻と前回のタイムスタンプを入れる変数の初期化
let start, prevTimestamap;
//FPS の値を表示する要素
const fpsOut = document.getElementById('fpsOut');
//1回のフレームの表示時間を表示する要素
const ftOut = document.getElementById('ftOut');

const showFPS = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  //1回のフレームの表示時間(小数点以下4桁で切り捨て)を表示
  ftOut.textContent = prevTimestamap ? Math.floor((timestamp - prevTimestamap)*10000)/10000 : 0;
  //prevTimestamap に現在のタイムスタンプを代入
  prevTimestamap = timestamp;
  //経過時間
  const elapsed = timestamp - start;
  //フレームの表示回数を1増加
  frameCount++;
  //経過時間が1秒を経過したら
  if(elapsed >= 1000) {
    //その時点のフレームの表示回数を fps に代入
    fps = frameCount;
    //フレームの表示回数をリセット
    frameCount = 0;
    //開始時刻をこの時点でのタイムスタンプに
    start = timestamp;
  }
  requestAnimationFrame(showFPS);
  //fps に値が入っていれば表示
  fpsOut.textContent = fps ? fps : ''; 
}
//関数を実行
showFPS();

Check FPS というボタンをクリックすると現在の FPS と1フレームの表示時間を表示します。

通常は FPS が 60前後(表示までに約1秒かかります)、frame time が 16.66xx(小数点以下4桁で切り捨て)と表示されると思います。

FPS の値は他のタブを(開いて)閲覧後、このページに戻って確認すると値が一時的に低い値になって、また元の60前後に戻るのが確認できます。

これはブラウザーのタブが非表示の状態にある場合は自動で FPS を低下させてメモリーの消費を抑えるようになっているためです。

FPS(Frame Per Second)
frame time(1 frame の時間 ms)

経過時間によるアニメーション

今までの例の場合、requestAnimationFrame() が呼び出されるタイミング、つまり、ブラウザが描画するタイミングで 1px ずつ移動するアニメーションなので、ディスプレイのリフレッシュレートによりアニメーションの速度が変わることになります(多くのブラウザーではディスプレイのリフレッシュレートに合わせてコールバック関数を呼び出すため、速度がリフレッシュレートに依存していることになります)。

requestAnimationFrame() のコールバック関数には引数として現在時刻のタイムスタンプが渡されるので、その値を使って経過時間によるアニメーションにすることで、リフレッシュレートに依存しないアニメーションを作成することができます(現在時刻は別途メソッドで取得することもできます)。

以下は 0.1px/ms の速度で 2 秒間(2000ms)移動する(200px 移動する)アニメーションの例です。

14行目では要素が移動する距離を経過時間を使って設定していますが、要素がちょうど 200px で止まるように Math.min() を使用しています。これは最後の関数の呼び出しの結果としての経過時間が2000をほんの少しだけ超える可能性があるためです。

//対象の要素
const elem = document.querySelector('.elem');
//開始時刻を代入する変数(最初は未定義)
let start;

const transX = (timestamp) => {
  if (start === undefined) {
    //初回実行時の timestamp を start に代入
    start = timestamp;
  }
  //経過時間(start が未定義の場合は0)
  const elapsed = start ? timestamp - start : 0;
  //要素が移動する距離を経過時間を使って設定(Math.min() で最大値を調整)
  const x = Math.min(0.1 * elapsed, 200);
  
  //経過時間が2秒未満の場合
  if (elapsed < 2000) { 
    //要素を x の値だけ移動
    elem.style.transform = `translateX(${x}px)`;
    //requestAnimationFrame() で繰り返し
    requestAnimationFrame(transX);
  }
}
//関数を実行
transX();

上記は以下のようにアニメーションの持続時間と移動距離という変数を定義して書き換えた方がわかりやすいかも知れません。

速度は distance/duration(200px/2000ms = 0.1px/ms)になります。

const elem = document.querySelector('.elem');
//開始時刻を代入する変数(最初は未定義)
let start; 
  
//アニメーションの持続時間
const duration = 2000;
//アニメーションの移動距離
const distance = 200;
 
const transX = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  const elapsed = start ? timestamp - start : 0;
  //要素が移動する距離を速度(distance/duration)と経過時間を使って設定
  const x = Math.min(distance/duration * elapsed, distance);
  
  //経過時間が持続時間に満たない場合
  if (elapsed < duration) { 
    elem.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(transX);
  }
}
//関数を実行
transX();

以下はボタンをクリックしたら上記と同じアニメーションを実行する例です。また、その際に経過時間と移動距離を出力するようにしています。

<div class="elem"></div>
<button type="button" id="play">Play </button>
<div>Elapsed Time : <span id="et_val"></span></div>
<div>value of x : <span id="x_val"></span></div>
const elem = document.querySelector('.elem');
let start;
const duration = 2000;
const distance = 200; 
  
//経過時間と移動距離の出力先の要素
const et_val = document.getElementById('et_val');
const x_val = document.getElementById('x_val');
const transX = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  const elapsed = start ? timestamp - start : 0;
  //経過時間を出力
  et_val.textContent = elapsed;
  //要素が移動する距離を速度(distance/duration)と経過時間を使って設定
  const x = Math.min(distance/duration * elapsed, distance);
  //移動距離を出力
  x_val.textContent = x;
  if (elapsed < duration) { 
    elem.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(transX);
  }
}
//ボタンの要素
const play =  document.getElementById('play');
//ボタンの要素にクリックイベントを設定
play.addEventListener('click', (e)=> {
  //開始時刻を初期化
  start = undefined;
  //関数を実行
  transX();
});

最終の経過時間は2000ミリ秒を少し超えるのが確認できます。そのため、移動距離をちょうど 200px で止まるようにするには Math.min() を使用します(上記では17行目)

Elapsed Time(経過時間)
value of x(移動距離)
注意:開始時刻を初期化する

前述の例のような経過時間を取得して行うアニメーションでは、1回だけコールバック関数を呼び出す場合は問題ありませんが、クリックしたらコールバック関数を呼び出すような再度コールバック関数を呼び出す場合、コールバック関数の呼び出しの前に開始時刻を初期化する必要があります。

必ずしも初期化が必要とは限りませんが、経過時間の値を元にした条件を使って requestAnimationFrame() を呼び出す場合などは、初期化が必要になることが多いです。

以下の場合、クリックイベントの設定で開始時刻(start)を undefined で初期化していないので、最初にボタンをクリックした際はアニメーションが実行されますが、2回目以降のクリックではアニメーションは実行されません。

<div class="elem"></div>
<button type="button" id="play">Play </button>
const elem = document.querySelector('.elem');
let start; //開始時刻を代入する変数
const duration = 2000;
const distance = 200; 
  
const transX = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  const elapsed = start ? timestamp - start : 0;
  
  //各値をコンソールに出力
  console.log('timestamp:' + timestamp);
  console.log('start:' + start);
  console.log('elapsed:' + elapsed)

  const x = Math.min(distance/duration * elapsed, distance);

  if (elapsed < duration) { 
    elem.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(transX);
  }
}

//ボタンをクリックしたら関数を実行するイベント
document.getElementById('play').addEventListener('click', (e)=> {
  //開始時刻を初期化していないので1回しかアニメーショは実行されない
  transX();
});

コンソールには以下のように出力されます。

最初のクリックで関数 transX() が呼び出される際は、requestAnimationFrame() のコールバック関数としてではなく、単に transX() として呼び出されるので、timestamp は undefined なので start も undefined になり、上記10行目の記述で elapsed は 0 になります。

そして requestAnimationFrame() のコールバック関数として呼び出される際には start はまだ undefined ですが、timestamp には現在の時刻が入っているので、上記8行目の記述で start にその時点でのタイムスタンプが開始時刻として代入されます。

この時、timestamp と start は同じタイムスタンプが入っているので elapsed は 0 ですが、それ以降 timestamp の値は増え続けて経過時間(elapsed)として取得できます。

2回目のクリックで関数 transX() が呼び出される際も、最初は requestAnimationFrame() のコールバック関数ではないので timestamp は undefined ですが、start にはすでに前回のクリックでの関数の実行により start の値が入っています。

そのため、上記10行目の記述により elapsed は timestamp - start になりますが、timestamp は undefined なので elapsed は NaN となり、その後の requestAnimationFrame() を呼び出す条件に入ることができず、処理は中断します。

//1回目のクリックでの出力
timestamp:undefined
start:undefined
elapsed:0
timestamp:1219.545
start:1219.545
elapsed:0
timestamp:1236.213
start:1219.545
elapsed:16.667999999999893
・・・中略・・・
timestamp:3390.066
start:1389.906
elapsed:2000.1599999999999
//2回目のクリックでの出力
timestamp:undefined
start:1389.906  //前回の start の値
elapsed:NaN

このため、経過時間を取得して行うアニメーションや次項の進捗度によるアニメーションなどで、ボタンをクリックしたらコールバック関数を実行するような場合は、必要に応じてコールバック関数を呼び出す前に開始時刻を初期化します。

この例の場合、start が最初は undefined であることにより開始時刻を取得・設定しているので undefined で初期化する必要があります。

document.getElementById('play').addEventListener('click', (e)=> {
  //開始時刻を undefined で初期化
  start = undefined;
  //関数を実行
  transX();
});
進捗度によるアニメーション

経過時間によるアニメーションと内容的にはほぼ同じことですが、経過時間とアニメーションの持続時間から現在の進捗度(アニメーションがどのくらい進行したか)を算出してアニメーションを設定することもできます。

こちらの方がよく使われる方法かも知れません。また、既存のイージング関数を使ってアニメーションにイージングを適用する場合はこちらのほうが扱いやすいです。

開始した時点での進捗度の値を 0、完了した時点での値を 1 とすると、その時点での進捗度は「経過時間/持続時間」として表すことができます。

例えば、進捗度を relativeProgress、経過時間を elapsed、アニメーションの持続時間を duration とすると以下のような関係になります。

relativeProgress = elapsed / duration

上記の relativeProgress(進捗度)の値は、0.0から1.0までの数値(浮動小数点数)になり、0 は 0% 、1 は 100% アニメーションが進行したことを表します。

以下は前述の例と同じことですが、relativeProgress という進捗度を表す変数を定義してアニメーションを設定する例です。

//対象の要素
const elem = document.querySelector('.elem');
//開始時刻を代入する変数(最初は未定義)
let start;
  
//アニメーションの持続時間
const duration = 2000;
//アニメーションの移動距離
const distance = 200;
 
const transX = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  //経過時間
  const elapsed = start ? timestamp - start : 0;
  //進捗度(1 で完了)
  const relativeProgress = Math.min(1, elapsed / duration);

  //要素が移動する距離を進捗度を使って設定
  const x = distance * relativeProgress;
  
  //進捗度が1に満たない場合(または経過時間が持続時間に満たない場合)
  if (relativeProgress < 1) { //または if (elapsed < duration) 
    elem.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(transX);
  }
}
//関数を実行
transX();
relativeProgress(進捗度)
value of x(移動距離)
relativeProgress(※)
value of x(※)

※ 3番目と4番目の値は、18行目で Math.min() を使わなかった場合の値を表示しています(ほんの少しだけですが、値が大きくなってしまいます)。

以下は約1秒に1ずつ値をカウントアップして表示する例です。

<div class="count">0</div>
<div class="value">0</div>
<div class="elapsed">0</div>
<button type="button" id="start">Start</button>

この例では、進捗度を使ってカウントの値を設定しています。アニメーションの持続時間(duration)を6000ms、カウントの最大値(maxCount)を6として、繰り返しの条件を「経過時間が持続時間未満」としているので、約1秒に1ずつ5までカウントアップされます。

カウントの値は進捗度に maxCount をかけた値の小数点以下を切り捨てたものです。

以下では、カウントの他に進捗度に maxCount をかけた値(Value)と経過時間(Elapsed)も出力するようにしています。

※ クリックイベントで requestAnimationFrame() に指定するコールバック関数を呼び出す際は、start を undefined で初期化する必要があります(開始時刻を初期化する)。

//値を出力する対象の要素
const countOut = document.querySelector('.count');
const valueOut = document.querySelector('.value');
const elapsedOut = document.querySelector('.elapsed');
//開始時刻を代入する変数(最初は未定義)
let start;
  
//アニメーションの持続時間
const duration = 6000;
//アニメーションで表示する値の上限 +1
const maxCount = 6;
//カウント 
let count = 0;
  
const showCount = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  //経過時間
  const elapsed = start ? timestamp - start : 0;
  
  //進捗度
  const relativeProgress = Math.min(1, elapsed / duration);
 
  //進捗度を使ってカウントの値を設定(小数点以下切り捨て)
  count = Math.floor(maxCount * relativeProgress);

  //経過時間が持続時間に満たない場合は繰り返し
  if (elapsed < duration) {   
    //カウントの値を出力
    countOut.textContent = count;
    //小数点以下を切り捨てない値を出力
    valueOut.textContent = maxCount * relativeProgress;
    //経過時間を出力
    elapsedOut.textContent = elapsed;
    //requestAnimationFrame() でコールバック関数を呼び出し
    requestAnimationFrame(showCount);
  }
}

//ボタンの要素
const startBtn =  document.getElementById('start');
//ボタンの要素にクリックイベントを設定
startBtn.addEventListener('click', ()=> {
  //開始時刻を初期化
  start = undefined;
  //関数を実行
  showCount();
});

Start ボタンをクリックするとカウントアップします。

Value と Elapsed は持続時間に達するまで表示されます。カウントは値が同じなので変わっていないように見えますが、実際には同じ値が同様に表示され続けています。

Count 0
Value 0
Elapsed (ms) 0

繰り返し条件に進捗度を指定する場合

上記では、繰り返し条件を if(elapsed < duration) としていますが、if(elapsed <= duration) のように <(未満)を <=(以下)としても elapsed は増え続けるのでほぼ同じ結果になります。

但し、if(relativeProgress < 1) とした場合、その代わりに if(relativeProgress <= 1) と指定すると、relativeProgress は1より大きくならないので、ループは継続されてしまいます。

進捗度を逆転(逆方向に移動)

開始した時点での進捗度の値を 1、完了した時点での値を 0 とすると、その時点での進捗度は「1 - 経過時間/持続時間」として表すことができます。

この場合、進捗度を reversedProgress、経過時間を elapsed、アニメーションの持続時間を duration とすると以下のような関係になります。

reversedProgress = 1 - elapsed / duration

例えば、前述のX軸方向に移動するアニメーションを逆方向に移動するには以下のように記述することができます。

//通常の進捗度を取得
const relativeProgress = Math.min(1, elapsed / duration);

//進捗度を逆転させて要素が移動する距離を設定
const x = distance * (1 - relativeProgress);

以下は Play ボタンをクリックすると右方向に移動し、Reverse ボタンをクリックすると左方向へ移動するアニメーションの例です。

<div class="elem"></div>
<button type="button" id="play">Play </button>
 <button type="button" id="reverse">Reverse </button>

この例では、reverse という変数に false を指定した場合は右へ、true を指定した場合は左へ(逆方向に)移動するようにしています。

移動距離を distance、進捗度を relativeProgress とするとその時点での位置は以下のようになります。

右へ:distance * relativeProgress

左へ:distance * (1 - relativeProgress) または distance - distance * relativeProgress

//対象の要素
const elem = document.querySelector('.elem');
//開始時刻を代入する変数(最初は未定義)
let start;
  
//アニメーションの持続時間
const duration = 2000;
//アニメーションの移動距離
const distance = 200;
//逆方向に進む場合は true   
let reverse = false;
 
const transX = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  //経過時間
  const elapsed = start ? timestamp - start : 0;
  //進捗度
  const relativeProgress = Math.min(1, elapsed / duration);
 
  //要素が移動する距離を進捗度を使って設定(reverse が true の場合は進捗度を逆転)
  const x = reverse ?  distance * (1 - relativeProgress) :  distance * relativeProgress;
  
  //進捗度が1に満たない場合
  if (relativeProgress < 1) {
    elem.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(transX);
  }
}

//Play ボタンをクリックしたら関数を実行するイベント
document.getElementById('play').addEventListener('click', (e)=> {
  //開始時刻を初期化
  start = undefined;
  //順方向に進捗度を適用
  reverse = false;
  //関数を実行
  transX();
});  

//Reverse ボタンをクリックしたら関数を実行するイベント 
document.getElementById('reverse').addEventListener('click', (e)=> {
  //開始時刻を初期化
  start = undefined;
  //逆方向に進捗度を適用
  reverse = true;
  //関数を実行
  transX();
}); 
現在時刻の取得

requestAnimationFrame() に指定したコールバック関数の引数に渡されるタイムスタンプを使わずに、独自に現在時刻を取得して使用することもできます。

現在時刻は performance.now()Date.now() などで取得できます。performance.now() が一番精度の高い時間を取得できるようです。

コールバック関数が引数に受け取るタイムスタンプは performance.now() で取得する値なので、前述の例のように引数のタイムスタンプを使えばほぼ同じことです。

const elem = document.querySelector('.elem');
let start;
const duration = 2000;
const distance = 200;
 
//引数のタイムスタンプを使わない例
const transX = () => {
  if (start === undefined) {
    //開始時刻に performance.now() で取得した値を代入
    start = performance.now();
  }
  //経過時間(現在時刻を performance.now() で取得)
  const elapsed = start ? performance.now() - start : 0;
  const relativeProgress = Math.min(1, elapsed / duration);
  const x = distance * relativeProgress;
 
  if (elapsed < duration) { 
    elem.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(transX);
  }
  console.log('x: ' + x);
  console.log('relativeProgress: ' + relativeProgress);
}

//ボタンの要素にクリックイベントを設定
document.getElementById('play').addEventListener('click', (e)=> {
  //開始時刻を初期化
  start = undefined;
  //または start = performance.now();
  //関数を実行
  transX();
});

クリックイベントなどでコールバック関数を呼び出す際は、開始時刻(start)を undefined で初期化するか、この方法の場合は start に performance.now()などで取得した現在時刻を設定することもできます。

cancelAnimationFrame()

cancelAnimationFrame() を使って、requestAnimationFrame() の呼び出しによる繰り返し処理を中止することができます。

以下が構文です(window. は省略可能)。

引数の requestID は requestAnimationFrame() の呼び出しにより返された値(戻り値)です。

window.cancelAnimationFrame(requestID);

以下は Start ボタンをクリックすると、hsl() を使って要素の背景色を変更するアニメーションを開始し、Stop ボタンをクリックするとアニメーションを中止する例です。アニメーションの際にはその要素の background-color の値(rgb() で表示される)を出力するようにしています。

requestAnimationFrame() を呼び出す際(25行目)に戻り値の ID を取得して、アニメーションを中止する際(42行目)に cancelAnimationFrame() にそのIDを指定します。

また、Start ボタンをクリックした際に Start ボタンの要素に disabled 属性を設定して、アニメーション中は再度ボタンをクリックできないようにしています。これは Start ボタンを複数回クリックすると、その回数分 Stop ボタンをクリックしないとアニメーションを完全に中止できないためです。

//アニメーション対象の要素
const elem = document.querySelector('.elem');
//開始時刻を代入する変数(最初は未定義)
let start;
//requestAnimationFrame() の戻り値のIDを格納する変数
let requestID;
//background-color の値を出力する要素  
const bgcolor = document.getElementById('bgcolor');
 
//コールバック関数の定義
const changeBGC = (timestamp) => {
  if (start === undefined) {
    //初回実行時の timestamp を start に代入
    start = timestamp;
  }
  //経過時間(start が未定義の場合は0)
  const elapsed = start ? timestamp - start : 0;
  //h (hsl の hue の値)を経過時間を使って生成
  const h = Math.floor(0.03 * elapsed) % 360;
  //要素の背景色を hsl() で設定
  elem.style.backgroundColor = `hsl(${h}, 90%, 50%)`;
  //background-color の値を出力
  bgcolor.textContent = elem.style.backgroundColor;
  //requestAnimationFrame() を呼び出して戻り値のIDを変数に代入
  requestID = requestAnimationFrame(changeBGC);
}

const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop');
//Start ボタンにクリックイベントを設定 
startBtn.addEventListener('click', (e) => {
  //アニメーションを開始
  changeBGC();
  //Start ボタンを disabled に
  e.currentTarget.setAttribute('disabled', true);
});
  
//Stop ボタンにクリックイベントを設定
stopBtn.addEventListener('click', () => {
  //アニメーションを中止(キャンセル)
  cancelAnimationFrame(requestID);
  //Start ボタンの disabled を解除
  startBtn.removeAttribute('disabled');
});

この例の場合、クリックイベントでコールバック関数を呼び出す際に start を undfined で初期化していませんが、2回目以降のクリックでも requestAnimationFrame() は呼び出されるのでアニメーションは実行されます。

クリックイベントでコールバック関数を呼び出す際に start を undfined で初期化すると、毎回同じ色から開始されます。

<div class="elem"></div>
<p>background-color: <span id="bgcolor"></span></p>
<button type="button" id="start">Start</button>
<button type="button" id="stop">Stop</button>

background-color:

イージングの適用

requestAnimationFrame() を使ったアニメーションにイージングを適用する1つの方法は、進捗度を算出してその値にイージングの関数を適用します。そしてイージングを適用した進捗度を使って移動する距離などを算出します。

言い換えると、進捗度をイージング関数で変換して、その値を使ってアニメーションさせます。

進捗度は経過時間に基づいて0.0から1.0の間の値になり、グラフにすると直線(linear)になります。

Time Passed(経過時間) Animation(値の変化) 0 0

例えば、進捗度に easeInOutExpo というイージングを適用すると以下のように最初はゆっくりで途中から急激に速くなり、最後はゆっくりになります。

Time Passed(経過時間) Animation(値の変化) 0 0

参考:cubic-bezier 関数のパラメータを使うと簡単にイージングをビジュアル化することができます。

イージング関数を定義して適用する例

//イージング easeInOutExpo の関数の定義
const easeInOutExpo = (x) => {
  return x === 0
  ? 0
  : x === 1
  ? 1
  : x < 0.5 ? Math.pow(2, 20 * x - 10) / 2
  : (2 - Math.pow(2, -20 * x + 10)) / 2;
}; 

/*・・・中略・・・*/

//経過時間を開始時刻とタイムスタンプから取得
const elapsed = start ? timestamp - start : 0;

//進捗度を算出
const relativeProgress = Math.min(1, elapsed / duration);

//進捗度にイージング easeInOutExpo を適用(進捗度をイージング関数で変換)
const easedProgress = easeInOutExpo( relativeProgress );

//イージングを適用して変換した進捗度を使って要素が移動する距離を算出
const x = distance * easedProgress;

以下はアニメーションに easeInExpo というイージングを適用する例です。

<div class="elem"></div>
<button type="button" id="start">Start</button>
//対象の要素  
const elem = document.querySelector('.elem');
//開始時刻を代入する変数(最初は未定義)
let start;
  
//アニメーションの持続時間
const duration = 2000;
//アニメーションの移動距離
const distance = 300;
//イージング easeInOutExpo の関数の定義
const easeInOutExpo = (x) => {
  return x === 0
  ? 0
  : x === 1
  ? 1
  : x < 0.5 ? Math.pow(2, 20 * x - 10) / 2
  : (2 - Math.pow(2, -20 * x + 10)) / 2;
}; 
 
//コールバック関数の定義
const transX = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  //経過時間
  const elapsed = start ? timestamp - start : 0;
  
  //進捗度を算出
  const relativeProgress = Math.min(1, elapsed / duration);
  
  //進捗度にイージングを適用(イージング関数で進捗度を変換)
  const easedProgress = easeInOutExpo( relativeProgress );
 
  //イージングを適用した進捗度を使って要素が移動する距離を設定
  const x = distance * easedProgress;
  
  //経過時間が持続時間に満たない場合(進捗度が1に満たない場合)
  if (elapsed < duration) { //または if (easedProgress < 1) { 
    elem.style.transform = `translateX(${x}px)`;
    //requestAnimationFrame() を使ってコールバック関数を繰り返す
    requestAnimationFrame(transX);
  }
}

//ボタンの要素
const startBtn =  document.getElementById('start');
//ボタンの要素にクリックイベントを設定
startBtn.addEventListener('click', ()=> {
  //開始時刻を初期化
  start = undefined;
  //関数を実行
  transX();
});

イージング関数

このページで使用しているイージングは以下のサイトのイージング関数を使わせていただいています。

https://easings.net/ja

例えば、easeInOutExpo のページの「数学関数」という部分に掲載されている関数を JavaScript 用に書き換えて利用しています。

変数 x はアニメーションの絶対的な進捗度を0(アニメーションの開始)と1(アニメーションの終了)の範囲で表します。

//イージング easeInOutExpo の関数の定義
const easeInOutExpo = (x) => {
  return x === 0
  ? 0
  : x === 1
  ? 1
  : x < 0.5 ? Math.pow(2, 20 * x - 10) / 2
  : (2 - Math.pow(2, -20 * x + 10)) / 2;
}; 

linear の場合は、何も変換せずに返せば良いと思うので以下のようになるかと思います。

const linear = (x) => {
  return x;
}

以下は easeInExpo の例です。

const easeInExpo = (x) => {
  return x === 0 ? 0 : Math.pow(2, 10 * x - 10);
}

例えば、以下のような値を上記の関数に与えるとそれぞれ異なる値を返します。

const progress = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
  
progress.forEach((val) => {
  console.log(linear(val));
});
  
progress.forEach((val) => {
  console.log(easeInExpo(val));
}); 

progress.forEach((val) => {
  console.log(easeInOutExpo(val));
}); 

以下が出力結果の例です。

linear easeInOutExpo easeInExpo
0 0 0
0.1 0.001953125 0.001953125
0.2 0.0078125 0.00390625
0.3 0.03125 0.0078125
0.4 0.125 0.015625
0.5 0.5 0.03125
0.6 0.875 0.0625
0.7 0.96875 0.125
0.8 0.9921875 0.25
0.9 0.998046875 0.5
1 1 1

BezierEasing(bezier-easing)

BezierEasing を使うと、cubic-bezier 関数を使ったイージングを適用することができます。

BezierEasing は npm でインストールして使うことができます。

例えば、CSS の ease-in-out に該当する cubic-bezier(0.42, 0, 0.58, 1)BezierEasing(0.42, 0, 0.58, 1) として定義して使用することができます(github.com/gre/bezier-easing)。

cubic-bezier() に指定できる値であれば、BezierEasing() に指定して使用することができます。

以下は前述の例を BezierEasing を使って書き換えた例です。イージングは ease-in-out の値を使用しています。

assets/src/index.js
//bezier-easing をインポート
import BezierEasing from 'bezier-easing';
//BezierEasing を使ってイージング関数を定義(ease-in-out)
const easing = BezierEasing(0.42, 0, 0.58, 1);

//対象の要素  
const elem = document.querySelector('.elem');
//開始時刻を代入する変数(最初は未定義)
let start;
  
//アニメーションの持続時間
const duration = 2000;
//アニメーションの移動距離
const distance = 300;
 
//コールバック関数の定義
const transX = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  //経過時間
  const elapsed = start ? timestamp - start : 0;
  
  //進捗度を算出
  const relativeProgress = Math.min(1, elapsed / duration);
  
  //進捗度にイージングを適用(イージング関数で進捗度を変換)
  const easedProgress = easing( relativeProgress );
 
  //イージングを適用した進捗度を使って要素が移動する距離を設定
  const x = distance * easedProgress;
  
  //経過時間が持続時間に満たない場合(進捗度が1に満たない場合)
  if (elapsed < duration) { 
    elem.style.transform = `translateX(${x}px)`;
    //requestAnimationFrame() を使ってコールバック関数を繰り返す
    requestAnimationFrame(transX);
  }
}
 
//ボタンの要素
const startBtn =  document.getElementById('start');
//ボタンの要素にクリックイベントを設定
startBtn.addEventListener('click', ()=> {
  //開始時刻を初期化
  start = undefined;
  //関数を実行
  transX();
});
index.html
<div class="elem"></div>  
<button type="button" id="start">Start</button> 
<script src="assets/dist/main.js"></script>

以下は npm でインストールして webpack でバンドルして使う場合の構成例です。

構成例
project
├── assets
│   ├── dist
│   │   └── main.js
│   ├── node_modules (bezier-easing 等)
│   ├── package.json
│   ├── src
│   │   └── index.js
│   └── webpack.config.js
└── index.html

または、インストールした node_modules/bezier-easing/dist の中に bezier-easing(.min).js があるので、そのいずれかを直接読み込むこともできます(その場合は import は不要です)。

<div class="elem"></div>  
<button type="button" id="start">Start</button> 
<script src="path/to/bezier-easing.min.js"></script>
<script>
//BezierEasing を使ってイージング関数を定義(ease-in-out)
const easing = BezierEasing(0.25, 1, 0.5, 1);
//対象の要素  
const elem = document.querySelector('.elem');
//開始時刻を代入する変数(最初は未定義)
let start;

/*・・・以下省略・・・*/

</script>

スムーススクロールにイージング

CSS の scroll-behavior や JavaScript の window.scrollTo() の behavior を使えば簡単にスムーススクロールを実装できますが、イージングのオプションはありません(おそらく)。

また、スクロール先の位置の調整などもできませんが、requestAnimationFrame() を使って独自にスムーススクロールを実装すればカスタマイズすることができます。

以下はスムーススクロールを requestAnimationFrame() を使って実装してイージングを適用する例です。

ページ内のリンク及びページトップへの移動をスムーススクロールするようにしています。

HTML(ページトップへのスクロールするボタンの)
<div id="scroll-to-top"><!--トップへスクロールするボタン-->
  <p>Top</p>
</div>
CSS(ページトップへスクロールするボタン)
#scroll-to-top {
  position: fixed; /* 画面右下に固定配置 */
  right: 15px;
  bottom: 2rem;
  z-index: 100;
  font-size: 0.75rem;
  background-color: rgba(0,0,0,0.35);
  width: 80px;
  height: 50px;
  clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
  color: #fff;
  line-height: 50px;
  text-align: center;
  transition: opacity .4s;
  opacity: .7;
  cursor: pointer;
}

#scroll-to-top:hover {
  opacity:1;
}

関数を呼び出す際は、現在のY軸方向のスクロール量(位置)を window.scrollY で取得して変数 currentY に、スクロール先の対象の要素のY軸方向のウィンドウ座標を getBoundingClientRect().y で取得して変数 targetY に代入しています。

リンク先のY座標(ドキュメント座標)は現在のY軸方向のスクロール量(currentY)にウィンドウの左上からの座標(targetY)を加えた値になるので、scrollTo() でその位置までスクロールさせます。

アニメーションで「移動する量」は targetY(スクロール先の対象の要素のY軸方向のウィンドウ座標)の値になります(図参照)。

そして、現在のY軸方向のスクロール量(currentY)に「移動する量に進捗度を適用した値」を加えることでアニメーションするようにしています。

  • 開始時:現在の位置 + 移動する量 * 進捗度(0) → 現在の位置
  • 終了時:現在の位置 + 移動する量 * 進捗度(1) → 現在の位置 + 移動する量 = リンク先のY座標

ページトップへスクロールするボタンをクリックした場合は、スクロール先の要素を html としてY座標(ウィンドウ座標)を指定して関数を呼び出しています。

※上記及びコードの一部を修正しました。(2022/4/23)

この例ではイージングに easeInCirc を使用しています。

//アニメーション(スムーススクロール)の持続時間
const duration = 800;
  
//開始時刻を代入する変数(最初は未定義)
let start;
  
//スクロール先の Y 座標(ウィンドウの左上からの座標)
let targetY;
  
//現在の垂直(Y)方向のスクロール量(位置)
let currentY;
  
//イージング関数の定義
const easeInCirc = (x) => {
  return 1 - Math.sqrt(1 - Math.pow(x, 2));
}
 
//コールバック関数
const smoothScroll = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  //経過時間
  const elapsed = start ? timestamp - start : 0;
  
  //進捗度を算出してイージングを適用
  const relativeProgress =  easeInCirc( Math.min(1, elapsed / duration) );
  
  //移動する量(targetY)に進捗度を適用して scrollTo のY座標へ指定する値を算出
  const scrollY = currentY + targetY * relativeProgress;

  //上記で算出した位置へスクロール
  window.scrollTo(0, scrollY);
  
  //進捗度が1未満の場合は自身を繰り返す
  if (relativeProgress < 1) {
    requestAnimationFrame(smoothScroll);
  }
}

//href 属性が # から始まる要素(内部リンク)を全て取得
const links = document.querySelectorAll('a[href^="#"]');
//特定の要素を除外する場合は :not() を使用(例.noSS を除外する場合は以下のように記述)
//document.querySelectorAll('a[href^="#"]:not(.noSS)');
  
//内部リンクが存在すれば 
if(links.length > 0) {
  //内部リンクのそれぞれの要素に対して以下を実行
  links.forEach((elem) => { 
    //それぞれの要素にクリックイベントを設定
    elem.addEventListener('click', (e) => {
      //href 属性の値を取得
      const href = e.currentTarget.getAttribute('href');
      //href 属性の値からスクロール先の要素を取得
      const target = href === "#" ? 
        //href 属性の値が # の場合は対象を html 要素
        document.querySelector('html') : 
        //それ以外は # 以降の部分を ID として対象の要素
        document.getElementById(href.replace('#', ''));
      
      //取得した要素が実際に存在すれば以下を実行
      if(target) {
        //開始時刻を初期化
        start = undefined;
        //対象(スクロール先)の要素の Y 座標(ウィンドウ座標) 
        targetY =  target.getBoundingClientRect().y;
        //現在の垂直方向にスクロールされている量
        currentY = window.scrollY;
        //関数を実行
        smoothScroll();
      }
    });
  }); 
}

//ページトップへスクロールするボタンのクリックイベント
document.getElementById('scroll-to-top').addEventListener('click', (e) => {
  //開始時刻を初期化
  start = undefined;
  //スクロール先の要素を html として Y 座標(ウィンドウ座標)を取得
  targetY =  document.querySelector('html').getBoundingClientRect().y;
  //垂直方向にスクロールされている量(位置)
  currentY  = window.scrollY;
  //関数を実行
  smoothScroll();
}); 

現在のY軸方向のスクロール量(window.scrollY)を currentY、スクロール先の対象の要素の Y軸方向のウィンドウ座標(getBoundingClientRect().y)を targetY とすると以下のような関係になります。

window (viewport) document target targetY (1000) currentY (0) 1000 スクロール先がビューポートの下にある(隠れている)場合
document target targetY (300) currentY(700) 1000 スクロール先がビューポート内にある場合
document target targetY ( -300) currentY (1300) 1000 スクロール先がビューポートの上にある(隠れている)場合

サンプルページ

スクロール先のオフセット

CSS の position: fixedposition: sticky を使って上部にメニューバーを固定した場合、通常にジャンプするとスクロール先がメニューバーにより隠れてしまうことがありますが、スクロール先の位置は簡単に調整することができます。

前述の例のスムーススクロールのコードをほんの少し変更するだけです。以下の例では19行目でスクロール先の位置を調整する値(ピクセル)を定義して、36行目でスクロール先を調整しています。

その他は前述のコードと同じです。位置を調整する値(以下の場合は offset)はメニューバーの高さなどにより、それぞれの環境に合わせて設定します。

SVG Ellipse 要素の ry 属性をアニメーション

以下は SVG の Ellipse 要素の ry 属性を使ったアニメーションの例です。

楕円の図形をクリックすると、Ellipse 要素の ry 属性の値を変化させるアニメーションで円にし、円になった図形をクリックすると元の楕円にアニメーションで変化します。

<div class="svg-wrapper" style="width: 100px;">
  <svg viewBox="0 0 100 100" class="svg-e01">
    <ellipse cx="50" cy="50" rx="50" ry="20" fill="pink" style="cursor:pointer;"/>
  </svg>
</div>

47行目ではイージングを適用した進捗度を使って ry 属性に指定する値(y)を設定しています。

楕円から円に変化させる(increase が true)場合は元の ry の値に差分に進捗度をかけた値を加算し、円から楕円に変化させる(increase が false)場合は rx 属性の値(円の場合の半径)から差分に進捗度をかけた値を減算しています。

また、進捗度が1になった場合の条件を設定して rx と ry の値が同じになる(完全な円になる)ようにしています。進捗度が1以下という条件(relativeProgress <= 1)にしてしまうと、requestAnimationFrame() が永遠に呼び出され続けてしまいます(見た目はアニメーションは終了しているように見えますが)。

//イージング関数(easeInOutExpo)の定義
const easeOutBounce = (x) => {
  const n1 = 7.5625;
  const d1 = 2.75;
  if (x < 1 / d1) {
    return n1 * x * x;
  } else if (x < 2 / d1) {
    return n1 * (x -= 1.5 / d1) * x + 0.75;
  } else if (x < 2.5 / d1) {
    return n1 * (x -= 2.25 / d1) * x + 0.9375;
  } else {
    return n1 * (x -= 2.625 / d1) * x + 0.984375;
  }
}; 
  
//楕円の要素
const ellipse = document.querySelector('.svg-e01 ellipse');
  
//開始時刻を代入する変数(最初は未定義)
let start;
  
//アニメーションの持続時間
const duration = 1000;
  
//楕円のX軸方向の半径
const rx = parseInt(ellipse.getAttribute('rx'));
//楕円のY軸方向の半径
const ry = parseInt(ellipse.getAttribute('ry')); 
//X軸方向とY軸方向の半径の差分
const diff = rx - ry;
  
//楕円から円に変化させる場合は true にする変数
let increase = true;
  
//コールバック関数の定義
const changeRY = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  //経過時間
  const elapsed = start ? timestamp - start : 0;
  
  //進捗度を算出してイージングを適用
  const relativeProgress = easeOutBounce(Math.min(1, elapsed / duration));

  //increase の値により進捗度を使って ry 属性の値を設定
  const y = increase ? ry + diff * relativeProgress : rx - (diff * relativeProgress);
  
  //進捗度が1に満たない場合
  if (relativeProgress < 1) { 
    ellipse.setAttribute('ry', y);
    //requestAnimationFrame() を使ってコールバック関数を繰り返す
    requestAnimationFrame(changeRY);
  }else if(relativeProgress === 1){
    //進捗度が1の場合(完全な円にするため)
    ellipse.setAttribute('ry', y);
  }
}
 
//ellipse の要素にクリックイベントを設定
ellipse.addEventListener('click', (e)=> {
  //開始時刻を初期化
  start = undefined;
  //現在のX軸方向とY軸方向の半径の値が同じでない場合(楕円の場合)
  if(ellipse.getAttribute('rx') !== ellipse.getAttribute('ry')){
    increase = true; 
  }else{
    //現在のX軸方向とY軸方向の半径の値が同じ場合(円の場合)
    increase = false;
  }
  //関数を実行
  changeRY();
});

ホバー時のアニメーション

以下は前述の例をトランジションのようなアニメーションに変更したものです。

HTML は前述の例と同じです。

JavaScript もイージングを適用していない以外は前述の例と似たような内容ですが、マウスアウトする際は cancelAnimationFrame() でマウスオーバー時のアニメーションを停止し、その時点でのY軸方向の半径を取得して、その値を使ってアニメーションを設定しています。

//楕円の要素 
const ellipse = document.querySelector('.svg-e01 ellipse');
  
//開始時刻を代入する変数(最初は未定義)
let start;
  
//アニメーションの持続時間
const duration = 400;
  
//楕円のX軸方向の半径
const rx = parseInt(ellipse.getAttribute('rx'));
//楕円のY軸方向の半径
const ry = parseInt(ellipse.getAttribute('ry')); 
//X軸方向とY軸方向の半径の差分
const diff = rx - ry;
  
//マウスオーバーの際は true、マウスアウトの際は false にする変数
let mouseEnter = true;

//リクエストID(キャンセル時に使用)
let requestID;
  
//コールバック関数の定義
const transitionRY = (timestamp) => {
  if (start === undefined) {
    start = timestamp;
  }
  //経過時間
  const elapsed = start ? timestamp - start : 0;
  //進捗度を算出
  const relativeProgress = Math.min(1, elapsed / duration);
  
  //mouseEnter の値により進捗度を使って ry 属性の値を設定 
  let y;
  if(mouseEnter) {
    //マウスオーバー
    y = ry + diff * relativeProgress;
  }else{
    //現在の楕円または円のY軸方向の半径を取得(マウスアウト)
    const currentY = parseInt(ellipse.getAttribute('ry'));
    y = currentY - ((currentY - ry) * relativeProgress);
  }

  //進捗度が1に満たない場合
  if (relativeProgress < 1) { 
    ellipse.setAttribute('ry', y);
    //requestAnimationFrame() を使ってコールバック関数を繰り返す
    requestID = requestAnimationFrame(transitionRY);
  }else if(relativeProgress === 1){
    ellipse.setAttribute('ry', y);
  }
}

//マウスオーバーイベント
ellipse.addEventListener('mouseenter', (e)=> {
  //実行中のアニメーションがあれば停止
  if(requestID) cancelAnimationFrame(requestID);
  //開始時刻を初期化
  start = undefined; 
  //mouseEnter を true に
  mouseEnter = true;
  transitionRY();
});
  
//マウスアウトイベント
ellipse.addEventListener('mouseleave', (e)=> {
  //実行中のアニメーションがあれば停止
  if(requestID) cancelAnimationFrame(requestID);
  //開始時刻を初期化
  start = undefined;
  //mouseEnter を false に
  mouseEnter = false;
  transitionRY();
});

CSS clip-path アニメーション

上記のような図形の場合、CSS の clip-path アニメーションを使えば非常に簡単に実装できます。

この例では SVG の rect 要素で正方形を作成し、CSS で clip-path に ellipse(50% 20%) を指定して楕円にしています。

HTML
<svg width="100" height="100" viewBox="0 0 100 100">
  <rect class="rect" x="0" y="0" width="100" height="100" fill="pink" />
</svg>

そして transition を設定し、ホバー時に円になるようにしています。

CSS
.rect {
  clip-path: ellipse(50% 20%);
  cursor: pointer;
  transition: clip-path .6s;
}
.rect:hover {
  clip-path: ellipse(50% 50%);
}

関連:CSS clip-path の使い方

また、ry または yx 属性を CSS プロパティとしてサポートしているブラウザ(現時点では Chrome のみ)では、ellipse 要素にも簡単にアニメーションを設定できます。

関連:path 要素の CSS アニメーション

他のアニメーションとの連携

例えば、Web Animation API で作成したアニメーションの再生位置や進捗度を取得すれば、アニメーション再生中にそれらの値を使って requestAnimationFrame() で他の操作と連携することができます。

以下は Element.animate() メソッドで作成したアニメーションのタイミングプロパテ(再生位置や進捗度)を使って requestAnimationFrame() でアニメーションを連携する例です。

animate() メソッドで作成したアニメーションの再生位置(localTime)を input 要素の value 属性に設定してスライダーをアニメーション表示し、進捗度(progress)を使ってメーターの針を回転させています。

Start End
localTime(再生位置)
progress(進捗度)
HTML
<div id="target"></div>
<div>
  <input id="timeRange" type="range" min="0" step="1" value="0" disabled>
  <!--max 属性の値は JavaScript で設定-->
</div>
<svg width="120px" height="140px" viewBox="-10 0 120 140" >
  <rect id="svgRect" x="10" y="70" width="50" height="4" transform="rotate(-40, 60, 72)" fill="red"/>
  <circle cx="60" cy="72" r="6"/>
  <text x="0" y="120">Start</text>
  <text x="85" y="120">End</text>
</svg>
<div>
  <button type="button" id="play">Play </button>
  <button type="button" id="pause">Pause </button>
  <button type="button" id="cancel">Cancel </button>
</div>
<p>localTime(再生位置) <span id="outputLocalTime">0</span> </p>
<p>progress(進捗度) <span id="outputProgress">0</span> </p>
CSS
#target {
  width: 50px; 
  height: 50px; 
  background-color: darkseagreen; 
  border-radius: 50%;
}
input[type="range"] {
  width: 300px; 
  margin: 25px;
}

animate()メソッドで作成したアニメーションの現在の再生位置(localTime)や進捗度(progress)は animation.effect.getComputedTiming() で取得したオブジェクトのプロパティとして取得できます。

以下ではアニメーションが再生中(playState が running)の場合、requestAnimationFrame() でコールバック関数を繰り返して、再生位置や進捗度を使ってアニメーションを表示しています。

メーターの針のアニメーションは rect 要素の transform 属性の値に進捗度を使って rotate() を適用し、スライダーのアニメーションは input 要素の value 属性に再生位置を指定して、requestAnimationFrame() を呼び出しています。

input 要素の max 属性の値は手動で 2000 と指定することもできますが、getComputedTiming() で取得したタイミングの endTime プロパティ(アニメーションの終了時間)の値を JavaScript で設定しています(26行目)。

また、キャンセルボタンをクリックした際は、Web Animation API のアニメーションと requestAnimationFrame() の両方をキャンセルしています。requestAnimationFrame() をキャンセルしないと、スライダーの位置がその際に初期化されません。

//Web Animation API アニメーション対象の緑色の円  
const target = document.getElementById('target');
//メーターの針を表す SVG の rect 要素
const rect = document.getElementById('svgRect');
//アニメーションの再生位置をアニメーション表示する type="range" の input 要素
const timeRange = document.getElementById('timeRange');
//緑色の円のアニメーションの再生位置の値(localTime)の出力先の要素
const outputLocalTime = document.getElementById('outputLocalTime');
//緑色の円のアニメーションの進捗度の値(progress)の出力先の要素
const outputProgress = document.getElementById('outputProgress');
 
//緑色の円の移動のアニメーションを Web Animation API の animate() メソッドで作成
const animation = target.animate(
  {
    transform: ['translateX(0px)', 'translateX(300px)']
  },
  {
    duration: 2000, 
    fill: 'both'
  }
);
//自動再生の停止
animation.cancel();

//スライダー(input type="range")の max 属性に endTime プロパティの値を設定
timeRange.setAttribute('max', animation.effect.getComputedTiming().endTime );
 
//equestAnimationFrame() の戻り値を格納する変数
let requestID;
  
//進捗状況のアニメーションや値を表示するコールバック関数
const displayTiming = () => {
  //タイミング情報を取得
  const timing = animation.effect.getComputedTiming();
  //現在の進捗度(timing の progress プロパティ)を取得
  const currentProgress = timing.progress;
  //メーターの針を表す rect 要素を現在の進捗度を使って回転
  rect.setAttribute('transform',`rotate(${-40 + currentProgress * 260},  60, 72)` );
  //現在の進捗度を出力
  outputProgress.textContent = currentProgress;
  //再生位置(timing の localTime プロパティ)を取得
  timeRange.value = timing.localTime;
  //再生位置の値を小数点以下を切り下げて出力
  outputLocalTime.textContent = Math.floor(timing.localTime);
  
  //アニメーションが再生中の間、requestAnimationFrame() でこの関数を繰り返し呼び出して実行
  if(animation.playState === 'running'){
    //戻り値を変数に代入
    requestID = requestAnimationFrame(displayTiming);
  }
}
 
//Play ボタンにクリックイベントを設定
document.getElementById('play').addEventListener('click', () => {
  //アニメーションを再生
  animation.play();
  //アニメーションの準備ができたら displayTiming() を呼び出す
  animation.ready.then(displayTiming);
});
  
//Pause ボタンにクリックイベントを設定
document.getElementById('pause').addEventListener('click', () => {
  //アニメーションを一時停止
  animation.pause();
});
  
//Cancel ボタンにクリックイベントを設定
document.getElementById('cancel').addEventListener('click', () => {
  //アニメーションをキャンセル(初期状態に戻す)
  animation.cancel();
  //requestAnimationFrame() をキャンセル
  cancelAnimationFrame(requestID);
  //メーターのアニメーションを初期状態に
  rect.setAttribute('transform','rotate(-40, 60, 72)');
  //スライダーのアニメーションを初期状態に
  timeRange.value = 0;
  outputLocalTime.textContent = '0';
  outputProgress.textContent = '0';
});

関連ページ:Web Animation API の使い方

参考サイト

以下のサイトを参考にさせていただきました。

以下のサイトにはイージング関数に関する詳しい解説があります。