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

この記事は [ドワンゴ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/dwango) 22日目の記事です。

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

[BCD Design](https://qiita.com/misuken/items/19f9f603ab165e228fe1) とか、スマートでスケールするコンポーネントのディレクトリ構成の話も書きたかったのですが、あまり時間が取れなかったので、今回は最近ライブラリとして作ってチームのメンバーやデザイナーさんに好評だった 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](https://developer.mozilla.org/ja/docs/Web/CSS/:active) はその名の通り要素がアクティブな状態、言うならば "押下中" (pressed) に近い状態を表すものなので、その操作が本当に実行されるかは無関係です。
ボタンの上でマウスダウンして、そのままマウスカーソルをボタン外に移動し、そこでマウスアップした場合、マウスダウン中は `:active` になりますが、ボタンからカーソルが外れた段階で `:active` は解除され、マウスアップしたときにボタンのクリックは実行されません。

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

### 基本戦略は data 属性の付与

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

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

### 連打の問題

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

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

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

1. [animation-name](https://developer.mozilla.org/ja/docs/Web/CSS/animation-name) を切り替える
2. animation のプロパティを一度外れた状態にする

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

### トリガーと余韻の考え方

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

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

```html
<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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41058/b5ad2a04-9e09-12e7-c547-cb29704a030d.gif)

#### ボタンを Enter で操作

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

![スクショ ActionTrigger button enter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41058/eaa6a7ce-d724-72b1-b60e-bddf5dbe3f52.gif)

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

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

![スクショ ActionTrigger anchor click.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41058/c3161dae-5053-8ee6-198b-0b7c484d079c.gif)

### 構成

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

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

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

## 使い方

### tsx 側の使い方

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

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

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

ActionTrigger.useSetup();
```

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

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

<ActionTrigger.Component />
```

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

### Sass 側の使い方

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

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

```scss
@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 の変化としてはこのようになりました。

```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-playing` も `true` ではなくイベントの種類にしておけば、イベントの種類によってアニメーションを変えられますね)

### mixin の実装

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

```scss
// 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]` の条件が一瞬だけ満たされます [^条件の拡張]
2. `animation: data-action-trigger 10ms linear;` が適用されます

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

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

4. `animationstart` を検知し、 `event.animationName` が `data-action-trigger` であることを確認します
5. `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` から余韻の時間を取得します [^getPropertyValue]
3. 余韻キャンセルのタイマーを仕掛けます
4. タイマーの発火により余韻終了の時間に `data-animation-playing="true"` を削除します
   1. もし余韻の最中にトリガーが引かれた場合はそこでタイマーと余韻のキャンセルを実行します

[^getPropertyValue]: `window.getComputedStyle(element).getPropertyValue("--data-action-animation-duration")`

## まとめ

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

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

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

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

## 最後に

### `animationend` を使わない理由

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

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

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

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

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

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

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