単一の CSS アニメーションキーフレームをイベントに応じて順再生・逆再生する

1 つの CSS アニメーションキーフレームを使い回してイベントに応じて順再生したり逆再生したりしたかったのですが、ハマったので原因や対処方法を紹介します。また、CSS アニメーションでの対応だけでなく、Web アニメーション API での対応がいい感じだったのでこちらも紹介します。

やりたいこと

クリックイベントでフェードイン・フェードアウトするアニメーションを CSS で設定したかったのですが思っている通りに動きませんでした。

以下のような HTML と JavaScript を用意します。ボタンをクリックすると対象の要素(ここでは空の div 要素)に表示・非表示用のクラスを付け外しする仕様です。

<button type="button" id="fade">Fade</button>
<div></div>
<script>
  document.querySelector('#fade').addEventListener('click', () => 
    document.querySelector('div').classList.toggle('open')
      ? document.querySelector('div').classList.remove('close')
      : document.querySelector('div').classList.add('close');
    );
</script>

次に以下のようなスタイルを適用します。ポイントは閉じるときは animation-direction: reverse を指定し、逆再生することで単一のアニメーションキーフレーム fade を開くときも閉じるときも使用するようにしていることです。

div {
  opacity: 0;
  width: 300px;
  height: 300px;
  background-color: #0000cc;
}

div.open {
  opacity: 1;
  animation-name: fade;
  animation-duration: 1s;
}

div.close {
  animation-name: fade;
  animation-duration: 1s;
  animation-direction: reverse;
}

@keyframes fade {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

これで上手くいくと思っていたのですが、初回ボタンをクリックしたときはアニメーションが再生され、フェードインされたのですが、2 回目ボタンをクリックしたときはアニメーションが再生されず、フェードアウトではなくクラス open が外されたことによって opacity: 0 が適用されて一瞬で消えてしまいました。

さらにその後ボタンをクリックするとフェードインアニメーションも再生されなくなりました。

環境

ブラウザ
Microsoft Edge 120.0.2210.77, Firefox 120.0.1

原因

CSS が何かまずいのか、そもそもアニメーションが再生されていないのか原因を切り分けるために以下のようにデバッグライトを仕込んでアニメーションが完了したらコンソールに出力するようにします。

document.querySelector('div').addEventListener('animationend', (e) => console.log(e));

結果、やはり 1 回目は出力されたのですが、2 回目以降フェードインでもフェードアウトでも出力されませんでした。つまりアニメーション自体が再生されいないということがわかりました。

ただ何故再生されないのかわからず、animation-direction: reverse がダメなのかなとか考え、一旦アニメーションキーフレームをフェードイン・フェードアウトで分けてみました。

div.open {
  opacity: 1;
  animation-name: fadein;
  animation-duration: 1s;
}

div.close {
  animation-name: fadeout;
  animation-duration: 1s;
}

@keyframes fadein {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

@keyframes fadeout {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

すると 2 回目以降でもフェードイン・フェードアウトそれぞれのアニメーションが再生されるようになりました。

この時点では animation-direction: reverse がよくないんだと思い、念のため以下のようにアニメーションキーフレーム fadeout の内容を fadein と同じにして、animation-direction: reverse を指定してみました。

/** 省略 */

div.close {
  animation-name: fadeout;
  animation-duration: 1s;
  /** animation-direction: reverse を追加 */
  animation-direction: reverse;
}

/** 省略 */

/** opacity: 1 -> 0 から 0 -> 1 に変更 */
@keyframes fadeout {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

するとこれでも動くじゃないですか…。

ここで原因は animation-name に指定しているアニメーションキーフレーム名が同一であることではと考え始め、animation-iteration-count プロパティ が頭にちらつき始めます。

<number>
アニメーションが繰り返される回数です。既定値は 1 です。 - animation-iteration-count - CSS: カスケーディングスタイルシート | MDN

animation-iteration-count は指定していませんので、上記の通り規定値 1 が適用されます。そして冒頭の単一のアニメーションキーフレームを使用したパターンの場合、初回ボタンクリック時に open クラスが適用されアニメーションキーフレーム fade が設定され、アニメーションが 1 度再生されます。

再度ボタンをクリックし、open クラスが取り除かれ、close クラスが適用されてもアニメーションキーフレームは fade のままです。そしてアニメーションキーフレーム fade は 1 度再生されているので animation-iteration-count: 1 により再生されることはありません。

アニメーションキーフレームをフェードイン・フェードアウトでそれぞれ用意した場合、毎回アニメーションキーフレームが切り替わるのでその度に再生回数がリセットされているのだと考えられます。

答えにたどり着くと当たり前な感じなのですが、なかなか気づきませんでした。

単一のアニメーションキーフレームでイベント毎にアニメーションを再生する

さて前項で原因が判明し、キーフレームを切り替えることでイベント毎にアニメーションを再生することはできたわけですが、上記の例のように単純なキーフレームならまだしも、複雑なキーフレームを単に逆再生したいがためだけに 2 つ用意するのはいけてない気がするので、単一のアニメーションキーフレームをイベント毎に再生する方法を考えます。

アニメーション終了後にアニメーションキーフレームをリセット(OK)

非表示 -> アニメーション順再生 -> 表示 -> アニメーション逆再生という状態を、それぞれクラス無し -> opening クラス -> open クラス -> closing クラスで管理し、非表示、表示のときは animation-name: none を指定することでアニメーション再生回数をリセットしてみます。

div {
  opacity: 0;
  width: 300px;
  height: 300px;
  background-color: #0000cc;
  animation-name: none;
  animation-duration: 1s;
}

div.open {
  opacity: 1;
  animation-name: none;
}

div.opening, div.closing {
  animation-name: fade;
}

div.closing {
  animation-direction: reverse;
}

@keyframes fade {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

JavaScript は 以下の通りで、非表示 -> アニメーション順再生、表示 -> アニメーション逆再生の状態遷移はボタンのクリックイベントで発火。アニメーション順再生 -> 表示、アニメーション逆再生 -> 非表示の状態遷移は animationend イベントで発火させます。アニメーション再生中にボタンがクリックされる可能性もあるのでボタンクリック時にこれから表示するのか非表示にするのかの判定は open クラスだけではなく opening クラスの有無も見て判断しています。

またアニメーション再生が何らかの理由でキャンセルされた場合も、アニメーション完了時と同じ処理を行うことで状態遷移が狂うのを防いでいます。

const div = document.querySelector('div');
    
document.querySelector('#fade').addEventListener('click', () => {
  if (div.classList.contains('open') || div.classList.contains('opening')) {
    div.classList.remove('open', 'opening');
    div.classList.add('closing');
  } else {
    div.classList.remove('closing');
    div.classList.add('opening');
  }
});

const handlerAnimEnd = () => {
  if (div.classList.contains('opening')) {
    div.classList.remove('opening');
    div.classList.add('open')
  } else {
    div.classList.remove('closing');
  }
}

div.addEventListener('animationend', handlerAnimEnd);
div.addEventListener('animationcancel', handlerAnimEnd);

animation-iteration-count: infinite で都度停止させる(NG)

上記と同様の状態管理を行いながら、animation-iteration-count: infinite にし、非表示、表示状態のときは animation-play-state: paused にしてアニメーション再生を停止させればいけるんじゃ…と思ってやってみたのですが、アニメーションを複数回再生する場合、アニメーション 1 回当たりの再生終了を検知するには animationiteration イベントで検知可能なのですが、このイベントが曲者で結果ダメでした。

animationiteration イベントは、 CSS アニメーションの反復が 1 回分終了し、次の回が始まったときに発生します。 - Element: animationiteration イベント - Web API | MDN

次の回が始まったときに発生するだと…。つまり順再生で opacity0 -> 1 だと 1 回再生されて、opacity: 1 になり、次の再生が始まって opacity: 0 になったときに発生するので、そのタイミングで animation-play-state: paused を指定したところで、アニメーションが停止されるのは opacity: 0 に戻っているのでフェードインした直後に消えて止まることになります。

実際にやってみましたがやはりその通りになってダメでした。

CSS はこんな感じで、animation-iteration-count: infinite にして、非表示、表示状態のときに animation-play-state: paused を指定してアニメーションを一時停止しています。

div {
  opacity: 0;
  width: 300px;
  height: 300px;
  background-color: #0000cc;
  animation-name: fade;
  animation-iteration-count: infinite;
  animation-duration: 1s;
  animation-play-state: paused;
}

div.open {
  opacity: 1;
  animation-play-state: paused;
}

div.opening, div.closing {
  animation-play-state: running;
}

div.closing {
  animation-direction: reverse;
}

@keyframes fade {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

JavaScript は先ほどとほぼ一緒で、animationend イベントの代わりに animationiteration イベントをリッスンしています。

const div = document.querySelector('div');

document.querySelector('#fade').addEventListener('click', () => {
  if (div.classList.contains('open') || div.classList.contains('opening')) {
    div.classList.remove('open', 'opening');
    div.classList.add('closing');
  } else {
    div.classList.remove('closing');
    div.classList.add('opening');
  }
});

const handlerAnimEnd = () => {
  if (div.classList.contains('opening')) {
    div.classList.remove('opening');
    div.classList.add('open')
  } else {
    div.classList.remove('closing');
  }
}

div.addEventListener('animationiteration', handlerAnimEnd);
div.addEventListener('animationcancel', handlerAnimEnd);

Web アニメーション API(OK)

ここまで CSS アニメーションで何とかしようとがんばっていましたが、もはやこれだけ JavaScript で色々やってたら全部 JavaScript でよくない?と思い、ウェブアニメーション API - Web API | MDN を使ってみることにしました。Web アニメーション API は比較的新しいブラウザ Web API で CSS アニメーションと同じ感覚で JavaScript からアニメーションの定義や実行ができます。また将来的には 2D のみならず 3D アニメーションにも対応していくようです。

先にコードを示しますがめちゃくちゃシンプルになりました。

div {
  opacity: 0;
  width: 300px;
  height: 300px;
  background-color: #0000cc;
}

div.open {
  opacity: 1;
}
const div = document.querySelector('div');
const keyframes = [{ opacity: 1 }, {opacity: 0}];
const keyframeEffect = new KeyframeEffect(div, keyframes, { duration: 1000 })
const animation = new Animation(keyframeEffect);

document.querySelector('#fade').addEventListener('click', () => animation.reverse());

const handlerFinish = ({ target }) => target.playbackRate > 0 ? div.classList.remove('open') : div.classList.add('open');

animation.addEventListener('finish', handlerFinish);
animation.addEventListener('cancel', handlerFinish);

まず KeyframeEffect インスタンスを作成します。KeyframeEffect コンストラクタの引数はいくつかパターンがありますがここでは

new KeyframeEffect(target, keyframes, options)

を利用しています。

target はアニメーションさせる DOM 要素なのでここでは div を渡しています。

target
The DOM element to be animated, or null.
keyframes
A keyframes object or null.
KeyframeEffect: KeyframeEffect() constructor - Web APIs | MDN

keyframes は keyframes オブジェクトと呼ばれるもので、実態は CSS アニメーションのアニメーションキーフレームと似たようなオブジェクトの配列です。

今回はこのように指定しています。

const keyframes = [{ opacity: 1 }, {opacity: 0}];

暗黙的に配列の最初の要素が CSS アニメーションキーフレームの 0% にあたり、最後の要素が 100% にあたります。 間のステップを指定する場合は offset プロパティで指定することが可能です。

Offsets for each keyframe can be specified by providing an offset value.

element.animate(
  [{ opacity: 1 }, { opacity: 0.1, offset: 0.7 }, { opacity: 0 }],
  2000,
);

Keyframe Formats - Web APIs | MDN

KeyframeEffect コンストラクタの引数の optionKeyframeEffect: KeyframeEffect() constructor - Web APIs | MDN を参照いただければと思いますが、delay, direction, duration 等、CSS アニメーションで使用する animation-* プロパティに加え、Web アニメーション API にしかないオプションもあります。

ここでは duration のみ指定しています。なお、CSS の animation-duration は CSS データ型の <time> - CSS: カスケーディングスタイルシート | MDN なので 1000ms, 1s のようにミリ秒でも秒でも指定できますが、Web アニメーション API の場合はミリ秒一択です。

さて次に作成した KeyframeEffect インスタンスを Animation() コンストラクタに渡して、Animation インスタンスを作成します。

Animation インスタンスはアニメーションを再生する play() や逆再生する reverse()、一時停止する pause() など CSS アニメーションでもプロパティとして用意されている機能を JavaScript で実行可能なメソッドを提供します。詳細は Animation - Web APIs | MDN をご参照ください。

なお、今回アニメーションの再生には常に reverse() で再生しています。そもそも keyframes オブジェクト作成時に最初の要素が opacity: 1 になっているので、おや?と思われたかもしれませんが、これには理由があります。

reverse() メソッドは MDN を見ると以下のように記載されていて単純にアニメーションを逆再生するメソッドに見えました。

The Animation.reverse() method of the Animation Interface reverses the playback direction, meaning the animation ends at its beginning. If called on an unplayed animation, the whole animation is played backwards. If called on a paused animation, the animation will continue in reverse.
Animation: reverse() method - Web APIs | MDN

従って、フェードイン時は順再生メソッド(と思っていた)の play()、フェードアウト時は逆再生メソッド(と思っていた)の reverse() と使い分けていました。

ところが 1 回目の表示 -> 非表示まではよかったのですが 2 回目表示したときに表示時にフェードアウトアニメーションが流れてしまいました。そして 2 回目非表示時にはフェードインアニメーションが流れるという不可解な動きになりました。

使い方が間違っている?それともバグ?と思い、W3C の仕様を確認してみました。

Inverts the playback rate of this animation and plays it using the reverse an animation procedure for this object.
Web Animations

プレイバックレートを反転させて再生すると書かれています。playbackRateAnimation インスタンスのプロパティで再生速度のようなものです。 負の値を含む実数を指定することができ、規定値は 1 です。

例えば playbackRate3 にすると 3 倍の速さでアニメーションが再生されます。そして負の値を指定すると逆再生かつ指定した速さで再生されることになります。詳しくは Animation: playbackRate property - Web APIs | MDN をご参照ください。

playbackRate はインスタンスプロパティなので、変更すると以降その値が引き継がれます。従って、reverse() メソッド呼び出し後は playbackRate が正負反転したままになっているので、現在の playbackRate に基づいて再生される play() メソッドは反転された playbackRate で再生するので期待した動きと反対方向にアニメーションが再生されてしまったようです。

そうするとアニメーション再生毎に playbackRate を反転してほしいので、普通に毎回 reverse() メソッドを使用している形になりました。

つまり reverse() メソッドを実行しているところは以下のように書いても同じ動きをします。

document.querySelector('#fade').addEventListener('click', () => {
  animation.playbackRate *= -1;
  animation.play();
});

reverse() メソッドの使い方だけ少しハマりましたが、結果 Web アニメーション API のほうがコントロールしやすくシンプルに実装できるなと思いました。

あとがき

単一のアニメーションキーフレームを任意のタイミングで再生できるようにしてみましたが、CSS アニメーションで実装した場合、結局状態管理の JavaScript が複雑になってしまい、それぞれアニメーションキーフレームを設定するほうが結果シンプルなのではと思いました。一方で、Web アニメーション API は使い勝手よく JavaScript にアニメーションコントロールを寄せられ、シンプルに実装できました。今後アニメーションを使うときは積極的に Web アニメーション API を活用していこうと思います。