🐹

animationstartイベントを使ってCSSからHTML要素のsvgを切り替えられるようにした話

2022/06/12に公開約8,900字

はじめに

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

今回はHTMLのanimationstartイベントを使って、CSSからHTML要素のsvgを切り替えられるようにした話を紹介します。

動機

  • 状態に応じて動的にsvgを切り替える処理が地味に面倒
  • :hoverでsvgを変えたい場合などCSSで書けたら楽なのにと思う場面が結構ある
  • svgだけ違うコンポーネントを幾つも作るのが面倒
  • svg違いのコンポーネントに名前が必要になる場合も面倒

svgの表示方法と問題点

svgの表示方法は、<svg>タグ、<img>タグ、<use>タグ、CSSのbackground-imageなど、色々な方法がありますが、CSSで細かく制御したい場合は<svg>タグや<use>タグを使う必要があります。

特に:hoverでsvgを切り替えたい場合や、複数の状態が絡んでsvgを切り替えたい場合、React側でHooksを書いて状態を管理する必要が出てくるなど、だいぶ面倒くさいことになります。

svg違いのためだけに書くコード

svgを出し分けるためだけに追加で書くコードにうんざりした経験は無いでしょうか?

条件分岐を何度も書いたり、マッピングを用意してみたり、ループの中でこのタイプはこのアイコンみたいなif文やswitch文、コンポーネントの大部分は同じだけどsvgだけ変えるためにpropsから渡せるようにするなど。視覚的な要素だし、CSSで書けたら一瞬で仕事終わりなんだけどなぁと思ったことが何度もあります。

svgは色々面倒

色々とsvg周りに面倒なことがあるので、CSSの疑似セレクタや属性セレクタを条件として、CSSのプロパティでsvgを指定できるかのごとく、簡単にHTML要素のsvgを切り替えられる方法を考えて実践してみました。

svgの出し分けに関するコードの全てをJSX上から排除できれば(ariaやdata属性に状態を反映することが前提)、コンポーネント実装時にはsvgは配置するものの、具体的に何を表示するかは考えなくてよくなります。具体的な意匠は視覚的な表現であるため、別途CSS側の責務として決定できたほうが開発もスムーズに進むと思います。

また、JavaScript上で書く条件分岐より、CSSの擬似セレクタや属性セレクタで条件を書いたほうがシンプルで書きやすく読みやすいという面や、CSS側でこの状態の時このsvgを表示するとなっていたほうが関心のまとまりとしても適していると思います。(アイコンによって色も合わせて変えたりするし)

CSS側からJavaScriptに連携する方法

CSS側からJavaScriptの処理を動かす手法としてはanimationstartイベントを活用しています。

特定の条件のとき、視覚的には意味のないダミーのアニメーションが発動するようにしておいて、そのイベントをReactのイベントハンドラで検知し、処理に繋げる方法です。

以前この方法を思い付き、他の用途で何回か使ったことがあるのですが、コールバックに渡ってくるeventオブジェクトのanimationNameからCSS側で指定したanimation-nameを取得できるので、React側で受け入れられるアニメーション名を定義し、CSS側はその定義に準じてanimation-nameを設定することで、依存関係も適切(CSSがHTML生成側の定義に依存)な状態で連携することができます。

ちなみに、animationendイベントはanimationstartに対して必ずしも1:1で発生するわけではなく、アニメーション中に要素が削除されたりすると発動しないので注意が必要です。

構成

今回紹介する範囲の構成。

node_modules
  @nicolive/react-svg // ニコニコ生放送のMonorepoのパッケージ
    index.scss        // 登録されたassetsから自動生成され、利用可能なsvg名の変数を提供するファイル
src
  base           // BCD Designの分類ディレクトリ
    svg          // Svgコンポーネント
      index.scss // svg依存の定数とmixinを提供するscss
      svg.scss   // このコンポーネントのクラス名
      Svg.tsx    // コンポーネントの実態
      story.scss // ストーリー表示用のsass
      Story.tsx  // Storybook用のファイル(CSF3.0形式)

index.scssにしている理由は同階層にあるscssファイルを最も簡潔に@useする方法をご覧ください。

@nicolive/react-svgindex.scssは自動生成されたただの定義ファイルです。

node_modules/@nicolive/react-svg/index.scss
$ReloadIcon: "ReloadIcon";
$CheckIcon: "CheckIcon";
// 以降使えるsvg名が並んでいます

これがあることで様々なメリットがあります。

  • エディタによって入力時の補完が効くので名前が探しやすい(IntelliJは効く)
  • typoしたらビルドでエラーになるので安心
  • リネームなどのリファクタも不安なく行える

assetsの登録に関連して、自動的にts側のファイルも生成されるようになっているので、tsは型で守られ、Sassは定数で守られ、それらが連動することにより、事実上はSass側も含めて型安全な状態で開発できるようになっています。

最終的な書き味

ストーリー用のscssファイルでは、ホバーによってsvgが切り替わるような指定が書いてあります。
任意のセレクターでmixinをincludeするだけで好きなsvgを表示できます。

src/base/svg/story.scss
@use "@nicolive/react-svg";
@use "." as *;

// ストーリー用の見た目調整用のmixin
@mixin base() {
    // 省略
}

// パターン1: 初期状態はリロードアイコン、ホバーするとチェックアイコンに変わる
.pattern1 {
    @include base;
    @include use(react-svg.$ReloadIcon);

    &:hover {
        @include use(react-svg.$CheckIcon);
    }
}

// パターン1: 初期状態はリロードアイコン、ホバーすると消える(`display: none`になる)
.pattern2 {
    @include base;
    @include use(react-svg.$ReloadIcon);

    &:hover {
        @include use(react-svg.$None);
    }
}

ストーリーはCSF3.0で書いています。

React側は空のSvgコンポーネントを置いてclassNameを渡すだけ。

src/base/svg/Story.tsx
import React from "react";
import Svg from "./Svg";
import classNames from "./story.scss";

export default {
  Component: Svg,
};

export const ホバーで切り替え = () => <Svg className={classNames.pattern1} />;
export const ホバーで非表示 = () => <Svg className={classNames.pattern2} />;

// カスタムプロパティをうまく利用しているので、svg要素の親からも遠隔でsvg要素を交換できます
// カスタムプロパティの値はanimation-nameへ渡す値になっています
export const ラップした要素に指定_ホバーで切り替え = () => {
  return (
    <div className={classNames.pattern1}>
      <Svg />
    </div>
  );
};

export const ラップした要素に指定_ホバーで非表示 = () => {
  return (
    <div className={classNames.pattern2}>
      <Svg />
    </div>
  );
};

ちなみに、src/base/svg/index.scssの中に定義されたuse()の中では、未定義の名前のsvg名を指定するとエラーになってビルドが通らないようにしてあります。

src/base/svg/index.scss
// このファイルは説明用に実際のコードから内容を省略してある部分があります
@use "@nicolive/react-svg";

$ns: "svg";
$p-animation-name: --#{$ns}-animation-name;

@mixin use($svgName) {
    @if global-variable-exists($svgName, react-svg) == false {
        @error "#{$svgName} はreact-svgに定義されていません";
    }

    // カスタムプロパティに適用しています
    #{$p-animation-name}: svg_#{$svgName};
}

デモ

いい感じにホバーでsvgが切り替わりました。

処理の流れ

Svgコンポーネントの処理の流れは以下のようになっています。

1.空のsvg要素を描画する

classにはsvg.scss(後述)に定義されたsvg切り替え用のanimationを含むセレクターを渡しますが、そのセレクターには:not([data-waiting="true"])が書いてあり、data-waiting="true"が付いている場合はアニメーションが発動しないようになっています。(これはSafariが@keyframesが無い状態でanimation適用状態になった場合、後から@keyframesが追加されてもanimationstartイベントが発火しないため)

<svg aria-busy="true" data-waiting="true" class="svg"/>

実際のコードからは端折ってあるのですが、svg.scssのイメージとしてはこういう内容になっています。

src/base/svg/svg.scss
// このファイルは説明用に実際のコードから内容を省略してある部分があります
@use "@nicolive/react-svg";
@use "." as *;

// 実際のコードはこれよりもう少し工夫して、svg自体に別途自由なanimationを適用できるようになっています
.svg {
    &:not([data-waiting="true"]) {
        // カスタムプロパティからアニメーション名を取得します
        animation: var(#{$p-animation-name}, react-svg.$None) .2s !important;
    }
}

2.useLayoutEffectで対応するanimation-nameの@keyframes<head>に埋め込む

以下のような<style>タグを作って<head>に埋め込みます。
この処理はidをチェックするので、このSvgコンポーネントを複数箇所に設置しても初回の1回だけ行われます。

<style id="svg-style-keyframes">
  @keyframes svg_None { from { opacity: 100% } to { opacity: 1 }}
  @keyframes svg_CheckIcon { from { opacity: 100% } to { opacity: 1 }}
  @keyframes svg_ReloadIcon { from { opacity: 100% } to { opacity: 1 }}
  <!-- 利用可能なsvg名全ての定義が並ぶ -->
</style>

3.data-waiting="true"を外す

useLayoutEffect後にwaiting状態が変更されるため、再描画が起こりdata-waiting="true"が外れます。

<svg aria-busy="true" class="svg"/>

4.アニメーションが発動してanimationstartイベントが発火される

data-waiting="true"が外れたことで以下の流れになります。

  1. セレクターの:not([data-waiting="true"])条件を満たすことになる
  2. そのセレクターに書かれているanimation-nameのプロパティが適用される
  3. animationが開始してanimationstartが発火される

5.animationstartイベントをハンドリングする

event.animationNameからアニメーション名を取り出し、固定フォーマットに埋め込まれたsvg名を抽出。setSvgName()で現在描画すべきsvg名が更新されます。

src/base/svg/Svg.tsx
    // `AliasKey`は`"CheckIcon" | "ReloadIcon"`などの利用可能なsvg名が定義された型
  const [svgName, setSvgName] = useState<AliasKey | "None">();
  const animationStartHandler: AnimationEventHandler = useCallback(event => {
    // svg_SvgName_ を含む形式のアニメーション名をsvg名として検出
    const svgName = event.animationName.match(/svg_([^_]+)/)?.[1];
    // 特定のアニメーション名の形式に該当しない場合はその他のアニメーションなので無視する
    if (svgName) {
      // scss側で自動生成で提供された定義を使用していれば必ず型が一致するので問題ない
      setSvgName(svgName as AliasKey);
    }
  }, []);

6.svgコンポーネントを作成して描画する

svgNameが更新されたあとの再描画で、対応するsvgを表示するコンポーネントを作成します。

createSvgComponent()関数は、assetsとして登録されたsvgファイルとそのエイリアス名の関係から自動生成されたマッピングオブジェクトを持つtsファイルを内部で参照し、引数で受け取ったsvgName(エイリアス名)からassetsのデプロイ先のURLを取得。そのURLをreact-inlinesvgに渡してsvgを表示するコンポーネントを生成します。(将来的には<use>に切り替えたい)

src/base/svg/Svg.tsx
  // 作成したコンポーネントはメモ化
  const SvgComponent = useMemo(() => {
    if (!svgName || svgName === "None") {
      return undefined;
    }
    return createSvgComponent(svgName);
  }, [svgName]);
  // classNames.svg は固定で読ませる
  const baseProps = {
    ...props,
    className: `${className || ""} ${classNames.svg}`,
    // また別のsvgに変わる可能性があるのでanimationstartは監視し続ける
    onAnimationStart: animationStartHandler,
  };

  if (SvgComponent) {
    // svgコンポーネントを描画
    return <SvgComponent onLoad={onLoad} onError={onError} {...baseProps} />;
  }

難しかったところ

animationstartのイベントを得るためのアニメーションのanimation-durationの時間が極端に短すぎると発火しないようなことがあったり、Safariで@keyframesを先に設置したあとでanimation-nameを適用しないとアニメーションが動かないなど、ブラウザ依存の細かい挙動の違いには結構ハマりました。(それもStorybook上では動いてて、結合環境で動かない的な厄介なパターン)

他にはSSRのときとCSRのときで、初回描画後のアニメーションがイベントハンドラの監視前に発火して空振りしてしまわないようにするなど、幾つかのポイントをクリアする必要があったので、一つ一つ問題にぶち当たりながらなんとか安定させていきました。

まとめ

CSSからHTML要素のsvgを切り替えられるようにする仕組みはいかがだったでしょうか?

このSvgコンポーネントは、先日リリースされた機能に使用されて本番稼働を開始しました。
インタフェースや使用感は非常に良いので、なるべく安定させて広く使っていけたら良いなと思っています。

複雑なCSSセレクターの条件によってsvgを細かく切り替えられるだけでなく、アニメーション名をカスタムプロパティで適用することで、svgの親や親の親の要素のセレクターからもsvgを切り替えることができたり、<svg>要素として表示されるため、svg内の細かい部分までCSSからスタイルを書き換えることができるなど、非常に重宝しそうです。

コンポーネントに対して、svgを予約状態で設置できることで、svg違いであってもReact側ではバリエーションを用意する必要がなくなるため、コンポーネント数の削減やロジックの削減にも効果を発揮します。

animationstartイベントは依存関係など設計面を注意した上でですが、うまく使えば意外なところで活躍してくれるので、表示に関する責務をよりCSS側に凝集するアイデアの参考にしてみてはいかがでしょうか?

Discussion

ログインするとコメントできます