2
1

More than 1 year has passed since last update.

CSS の :active 並みの書き味でクリックアニメーションを汎用化した

Last updated at Posted at 2021-12-21

この記事は ドワンゴ Advent Calendar 2021 22日目の記事です。

ドワンゴでニコニコ生放送のWebフロントエンジニアをやっています、 @misuken です。

BCD Design とか、スマートでスケールするコンポーネントのディレクトリ構成の話も書きたかったのですが、あまり時間が取れなかったので、今回は最近ライブラリとして作ってチームのメンバーやデザイナーさんに好評だった CSS アニメーション周りの話をします。

発端

ニコニコ生放送では、tsx はエンジニア、 scss はデザイナーが担当し、それぞれの責務をそれぞれの領域で完結できる体制で開発しています。そんな中、以前からデザイナーさんが「クリック時にアニメーションさせる仕組みがほしい」という要望をもらうことがありました。

いわゆる "クリック感" を出すためにアニメーションを使いたいといったものです。

前提

  • React + TypeScript + Sass
  • IE11 は考えなくて良い

実現したいこと

  • アニメーション追加時に Sass 側のみで完結できるようにしたい
  • セレクターごとに好きなアニメーションを指定できるようにしたい
  • エンジニアはアニメーション追加の度に作業が発生しないようにしたい
    • アニメーションする要素をいちいち何かで囲うみたいなことはしたくない
    • アニメーションするコンポーネントにアニメーションへの依存を含めたくない(hooks や Props 等)
  • なるべく汎用的に使える仕組みにしたい

そんなに難しく無さそうに思えるかもしれませんが、エンジニアが全く手を加えず、 Sass を触るデザイナーさんだけで好きな場所にアニメーションを追加していける状態を実現しようとすると、結構工夫が必要になるので、同じようなことを考えている方にはこの実装がヒントになるかもしれません。

色々な問題と解決

:active は使い物にならない

最初に疑似クラスの :active を使って Sass 側で何とかならないものか?と思ったのですが、デザイナーさんと色々確認したところ :active は全く役に立たないことがわかりました。

例えばクリック操作では、マウスダウンのタイミングで :active 状態となり、ボタンクリックが確定するマウスアップ時に :active 状態ではなくなります。
つまり、クリックが確定したところでアニメーションしたいのに、アニメーションしたいタイミングでは肝心の :active が使えません。

ならば :active から :not(:active) に変わるところをアニメーションすればと思うかもしれませんが、 :not(:active) は平常時を表すので、要素が最初に表示されたタイミングでも作動してしまいます。

:active は操作の実行とは無関係

そもそも :active はその名の通り要素がアクティブな状態、言うならば "押下中" (pressed) に近い状態を表すものなので、その操作が本当に実行されるかは無関係です。
ボタンの上でマウスダウンして、そのままマウスカーソルをボタン外に移動し、そこでマウスアップした場合、マウスダウン中は :active になりますが、ボタンからカーソルが外れた段階で :active は解除され、マウスアップしたときにボタンのクリックは実行されません。

このように、操作実行時にアニメーションを発動したい場合に :active と紐付けようとすることは不可能であると言えます。

基本戦略は data 属性の付与

:active で無理なことははっきりしたので、それ以外の方法で Sass 側でアニメーションを制御するならば、data 属性を使うことが妥当と言えます。
アニメーションすべき操作が行われたタイミングで data 属性が付与され、アニメーションが終了したら消えるイメージです。

<button>ボタン</button>
<button data-animation-playing="true">ボタン</button> ← 通常時からアニメーション中への変化
<button>ボタン</button> ← アニメーション中から通常時への変化

連打の問題

ボタンなどの UI は連打できたりするため、アニメーション中でも次のアニメーションを実行する必要があります。
次に発生するアニメーションは最初からアニメーションほしいわけですが、単純に実装するとアニメーション中に次のアニメーションへの切り替えを行おうと思っても data 属性としては変化が起きないため、前のアニメーションが継続された状態になってしまいます。

<button>ボタン</button>
<button data-animation-playing="true">ボタン</button> ← 通常時からアニメーション中への変化
<button data-animation-playing="true">ボタン</button> ← アニメーション中からアニメーション中への変化(変化しない・・・)
<button>ボタン</button> ← アニメーション中から通常時への変化

同じアニメーションを再度最初から行うためには、以下のいずれかの方法が必要になります。

  1. animation-name を切り替える
  2. animation のプロパティを一度外れた状態にする

このうち、1つ目は Sass 側でアニメーション名を2つ用意する必要があるなど、Sass 側の記述が煩雑になるので2つ目の方法を取ることにしました。

トリガーと余韻の考え方

色々と考察を進める中で一つの汎用的なパターンとして見えてきたのが、トリガーと余韻の考え方です。
トリガーとはアニメーションを発動させるきっかけであり、そのきっかけを満たす条件が付いたときに一瞬だけ存在する状態です。
そして、トリガーの後に余韻の時間があり、余韻が終わると元の状態に戻るという流れ。

これであれば、ボタンが連打された場合も一瞬トリガーが発動するタイミングで余韻が途切れて animation のプロパティが一度外れた状態になるので、後続のアニメーションは最初から開始されます。

<button>ボタン</button>
<button>ボタン</button> ← トリガー(ボタンクリック)が発動
<button data-animation-playing="true">ボタン</button> ← 余韻開始
<button>ボタン</button> ← トリガー(ボタンクリック)が発動
<button data-animation-playing="true">ボタン</button> ← 余韻開始
<button>ボタン</button> ← 余韻終了

操作の実行に該当するもの

操作の実行、つまり何がアニメーションを発動させるトリガーになるのかをまとめると以下のようになります。

要素 Click Enter(key down) Space(key up) disabled aria-disabled="true"
<button>
<* role="button"> -
<a> -
<* role="link"> -
<* role="tab"> -

※ キーボード操作はその要素がフォーカスされている必要があります

ざっと挙げるとボタンだけでなくアンカーや role で指定された物も対象に入ってきます。
現在は含んでいませんが、ラジオボタンやチェックボックスといった入力要素も対象に入ってくるかもしれません。

また、アンカーがフォーカス時でもスペースキーで操作できない点や、 disabled(aria-disabled) 状態のときにアニメーションが発動しないように気を付けなければなりません。

完成

使用感

ボタンをクリックで操作

連打してもいい感じに動きます。

スクショ ActionTrigger button click.gif

ボタンを Enter で操作

Enter の押しっぱなしでもうまく動きます。

スクショ ActionTrigger button enter.gif

アンカーをクリックで操作

アンカーでもボタンと同様に動きます。

スクショ ActionTrigger anchor click.gif

構成

ディレクトリ構成はこんな感じで、コンポーネントと mixin を提供する Sass からなるシンプルなもの。

action-trigger
├── ActionTrigger.tsx
└── index.scss

ちなみに、コンポーネントディレクトリ内の @use 向けの scss ファイルは index.scss にしておくと利用時に簡潔に書けるのでおすすめです。

使い方

tsx 側の使い方

hooks 式とコンポーネント式の2つの使い方があります。

hooks 式はアプリケーション内のどこかで以下の hooks を実行するだけ。

import * as ActionTrigger from "path/to/action-trigger/ActionTrigger";

ActionTrigger.useSetup();

コンポーネント式の場合はアプリケーション内のどこかで以下を配置するだけ。
コンポーネントは内部で ActionTrigger.useSetup() を呼ぶことしかしていません。

import * as ActionTrigger from "path/to/action-trigger/ActionTrigger";

<ActionTrigger.Component />

どこででも一回呼ばれれば準備が整う作りになっているため、本番用にはページのコンポーネント、 Storybook 用には全体に適用可能な Decorator などに仕込むだけです。
一度設置したらエンジニアはそれ以降一切作業が発生しません。

Sass 側の使い方

action-trigger/index.scss から提供される mixin である use を使い、 {} の部分に余韻の最中に適用するスタイルを書きます。
mixin の引数で余韻を維持する時間の指定も可能となっており、Sass 側で全ての責務を完結できます。

まるで :active の代わりに疑似セレクタが追加されたくらいの簡潔さです。

@use "path/to/action-trigger";

// 装飾のスタイルは省略しています

.button {
  // 以下の指定の場合、ボタンのクリック、またはフォーカス中の Enter or Space キー操作時に余韻はデフォルトの3秒間続きます
  @include action-trigger.use(0.2s) {
    animation: anime 0.2s linear;
  }
}
}

.anchor {
  // 以下の指定の場合、ボタンのクリック、またはフォーカス中の Enter キー操作時に余韻は5秒間続きます
  @include action-trigger.use(0.5s) {
    animation: anime 0.5s linear;
  }
}

@keyframes anime { /* 省略 */ }

仕組み

HTML の変化

最終的に HTML の変化としてはこのようになりました。

<button class="foo-button">ボタン</button>
<button class="foo-button" data-action-trigger="click">ボタン</button> ← トリガー(ボタンクリック)が発動
<button class="foo-button" data-animation-playing="true">ボタン</button> ← 余韻開始
<button class="foo-button" data-action-trigger="click">ボタン</button> ← トリガー(ボタンクリック)が発動
<button class="foo-button" data-animation-playing="true">ボタン</button> ← 余韻開始
<button class="foo-button">ボタン</button> ← 余韻終了

トリガーとなったイベントの種類を属性に反映した理由は、イベントの種類によっても Sass 側でアニメーションするかどうかを決定したい場合に使えそうだからです。( data-animation-playingtrue ではなくイベントの種類にしておけば、イベントの種類によってアニメーションを変えられますね)

mixin の実装

Sass で提供する mixin の実装は簡単なので丸ごと紹介します。

// action-trigger/index.scss
@mixin use($durationMs: 3000ms) {
  // トリガーの条件を判定するセレクター
  &[data-action-trigger] {
    // 余韻の開始を告げる animationstart を発火するためのアニメーション
    animation: data-action-trigger 10ms linear;
  }

  // 一瞬同時に付く場面があったりするので、念のため `:not([data-action-trigger])` も付けてあります
  &[data-animation-playing="true"]:not([data-action-trigger]) {
    // 余韻の時間
    --data-action-animation-duration: #{$durationMs};

    // 余韻の最中に適用されるスタイル
    @content;
  }
}

// イベント発火目的なので空のアニメーションで良い
@keyframes data-action-trigger {
  from { /*!*/ }
  to { /*!*/ }
}

全体の流れ

JS の実装はそれなりにコード量があるので省きますが、やっていることは以下のようなことです。

1. 事前準備(JS)

  1. コンポーネントか hooks を設置した場所で ActionTrigger.useSetup() が呼ばれます
  2. 描画後の useEffect()document.addEventListener により keydown keyup click animationstart を監視します

2. トリガーの処理(JS)

  1. 利用者が操作を実行します
  2. keydown keyup click イベントを検知し、トリガー条件を満たすアクションであることを確認します
  3. 対象要素に data-action-trigger="click | keydown | keyup" を付与します
  4. 次のフレームで付与した属性を削除します(data-action-trigger が付くのは一瞬だけ)

3. アニメーション開始の判定(CSS)

  1. CSS の &[data-action-trigger] の条件が一瞬だけ満たされます 1
  2. animation: data-action-trigger 10ms linear; が適用されます

4. アニメーション開始決定の検知(JS)

  1. animationstart を検知し、 event.animationNamedata-action-trigger であることを確認します
  2. event.target (つまり mixin を適用した要素)に対して data-animation-playing="true" を付与します

5. 余韻の発生(CSS)

  1. &[data-animation-playing="true"] が満たされます
  2. 任意のアニメーションが開始されます

6. 余韻の処理(JS)

  1. animationstart を検知し、イベント発生要素に data-animation-playing="true" が付いているか調べます
  2. カスタムプロパティ --data-action-animation-duration から余韻の時間を取得します 2
  3. 余韻キャンセルのタイマーを仕掛けます
  4. タイマーの発火により余韻終了の時間に data-animation-playing="true" を削除します
    1. もし余韻の最中にトリガーが引かれた場合はそこでタイマーと余韻のキャンセルを実行します

まとめ

:active は操作の実行とは無関係であり、操作の実行に対してアニメーションを実装したい場合はトリガーと余韻の考え方を使うと連打などにもうまく対応できることがわかりました。

アニメーション適用時に Sass 側のみで完結できるようにするには、 animationstart イベントの活用や、JS からカスタムプロパティを参照するなど、様々な工夫が必要になりますが、そこを超えると簡単な mixin 1つで操作の実行に対応したアニメーションを実現していけるようになります。

ここまで来ればあとはデザイナーさんが良い感じにクリック感のあるアニメーションを適用してくれますし、キーボードからの操作でも自動的にいい感じに動いてくれるので、エンジニアは何もすることがありません。めでたしめでたし。

今後、このライブラリを使いたいという声があれば公開するかもしれませんので、ご要望があれば教えて下さい。

最後に

animationend を使わない理由

余韻の終了に animationend イベントを使わず、タイマーを使っているのにはいくつかの理由があります。

  • 要素へ適用するアニメーション名は自由に書ける状態になっており、 JS 側で animationend 時の余韻のアニメーション名を判別できない
  • animationend は必ずしも animationstart と同じ回数発生するとは限らないので animationend が発生しなかったときのことも考える必要が出てくる
  • 疑似要素の ::before ::after に対するアニメーションを含む場合もあり、最後に終わるアニメーションを判別できない

余韻を消す必要性に関して

実はよくよく考えてみると余韻を表現する data-animation-playing="true" は消さなくても問題ないのでは?と思えてきたりもします。

消さなくてもアニメーションは勝手に終わりますし、次のトリガーが引かれたときは再び最初からアニメーションが開始するためです。
余韻が残り続けても良いなら(表現的に気持ち悪いので消したくなりますが)、処理部分を大胆に削れるのでありがたくもあります。

しかし、属性が残り続けていると思わぬタイミングでアニメーションが発動することが起こりえます。
例えば、対象の要素やその親要素が display: none で非表示になり、その後再び表示された場合などです。

予想外のタイミングでアニメーションが発動しないためにも、余韻は適切なタイミングで消す必要があると考えています。


  1. ここでアニメーション実行の最終決定権(属性セレクタの条件)を Sass 側に持たせることにより、 mixin で引数を受け取り &[data-action-trigger="click"] として、 click のときだけアニメーションさせるといった拡張も Sass 側のみで可能です。 

  2. window.getComputedStyle(element).getPropertyValue("--data-action-animation-duration") 

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1