🏠

CSS Modules で作る SVG Icon Component

2023/07/27に公開3

SVG Icon Component とは

以下のように「種類・サイズ・色」などの props を組み合わせ、ユースケースに応じてスタイルを切り替えるコンポーネントのことを指します。大抵、どんなプロジェクトでも使われているのではないでしょうか。

<Icon type="camera" size="small" color="orange" />

この<Icon />コンポーネントを使用すると、以下キャプチャのようになります。

SVG Icon Component

UI ライブラリから提供されているものを使用する選択肢もありますが、デザイナーが用意した SVG を使用する場合は自作する必要があります。自作<Icon />コンポーネントのフォルダ構成は、概ね以下のようになるでしょう。

components/Icon
├── assets
│   ├── camera.svg
│   ├── gear.svg
│   ├── heart.svg
│   └── home.svg
├── index.tsx
└── style.module.css

SVG Icon Component をどう作るか

アイコン画像を背景画像ではなく SVG にする理由は「塗り色」を動的に変更したいためです。新色を追加するとき、全種のアイコン画像を追加するのは大変です。SVG であれば、要素に対しpath { fill: #ff0; }のように CSS 指定をすることで動的に塗り色を変更できるため、このようなケースでは「インラインレンダリング」が選択できます。インラインレンダリングであれば、塗り色だけでなくサイズも動的に変更できます。

ただし、インラインレンダリングには課題があります。

  • Next.js や Storybook で表示するためには、@svgr/webpackのような loader が必要
    • 後日バージョンアップで手こずり、時間を溶かす原因になりがち
    • Next.js 対応はできたが Storybook が通らない…など
  • レンダリングキャッシュに SVG が埋め込まれる(Next.js)
    • レンダリングコストがかかる(大容量 SVG はメモリにも悪影響)

筆者の経験範囲では SVG をインラインレンダリングする要求は、この Icon Component ぐらいしかありません。動的に SVG 形状を変更する要求がなければ、できるだけ簡素にすませたいところです。

mask-image を使った実装方法

SVG Icon Component の実装は CSS だけで可能です。まず、String Literal 型でパターンを Props 型に書き出します。CSS Modules でも Literal 型を使用すれば、ある程度カッチリ書くことができます。

type Props = {
  type: "camera" | "gear" | "heart" | "home";
  size?: "small" | "medium" | "large";
  color?: "black" | "gray" | "orange";
};

className を合成する clsx は必須ではありませんが、あると楽です。以下実装例ではstyles[type]のように添字アクセスで Props から取りうる CSS セレクター名称を結合しています。

import clsx from "clsx";
import styles from "./style.module.css";

export function Icon({ type, size = "small", color = "black" }: Props) {
  return (
    <span
      className={clsx(styles.icon, styles[type], styles[size], styles[color])}
    />
  );
}

CSS 指定は以下のとおりです。::before擬似要素に SVG を表示します。CSS の mask-image プロパティを使用すると、SVG の形状で切り抜かれた背景色が表示されます。

.icon::before {
  content: "";
  display: inline-block;
  mask: no-repeat center; /* 👈 */
  mask-size: contain; /* 👈 */
}

/* size */

.small::before {
  width: 24px;
  height: 24px;
}

.medium::before {
  width: 36px;
  height: 36px;
}

.large::before {
  width: 48px;
  height: 48px;
}

/* color */

.black::before {
  background-color: var(--gray-900);
}

.gray::before {
  background-color: var(--gray-500);
}

.orange::before {
  background-color: var(--orange-900);
}

/* type */

.camera::before {
  mask-image: url("./assets/camera.svg"); /* 👈 */
}

.gear::before {
  mask-image: url("./assets/gear.svg"); /* 👈 */
}

.heart::before {
  mask-image: url("./assets/heart.svg"); /* 👈 */
}

.home::before {
  mask-image: url("./assets/home.svg"); /* 👈 */
}

多ブラウザ検証はできていませんが、caniuse を見る限りでは問題なさそうです。

CSS Modules で読み込んだ SVG ファイルは React コンポーネントとしてではなく静的ファイルとしてキャッシュされるため、色々メリットがあると考えています。

Discussion

misukenmisuken

この方法色々と便利でパフォーマンスも良いのですが、url() の部分でカスタムプロパティを使用し、なおかつ相対URLを使用する場合、Safari14系までバグがあるので注意が必要です。
https://zenn.dev/misuken/articles/adf0a3072560ea

また、CSSではなくSassであれば、塗りの有無やサイズや形状等を簡単に制御できる smart-svg というライブラリを公開しているので、興味のある方は触ってみてはいかがでしょうか。

TakepepeTakepepe

コメントありがとうございます。当記事は Next.js で標準搭載されている CSS Modules を使用する想定で執筆しています。styles.module.css から相対パスで読み込まれた url は /_next/static/media/xxxx.svgのようなルートパスに変換されるため、問題にはならないように思います。

Safari14系までバグがあることは初見でしたので、カスタムプロパティを使用したい場合は、以下のように指定し、publicフォルダに SVG を格納すると良さそうですね。

:root {
  --camera-icon: url('/assets/camera.svg');
}
misukenmisuken

返信ありがとうございます。

アクセスするページとCSSファイルも同じオリジンになるでしょうし、問題なさそうですね。

Safariのバグは問題が生じていることに気付きにくいので、バグのパターンに合致しないことを確認の上使えると安心ですね。