🥶

本当にあった怖いSVGアイコンの話

2022/10/02に公開

...

...ぴちょん。

...ぴちょん。

...ぴちょん。

すっかり暗くなった教室に麻子は一人残されていた。
どこかで水道の蛇口が緩くなっているのだろうか、
水の滴る不気味な音が誰もいない静かな廊下に木霊する。

麻子は急いで帰ろうと思ったが、急にトイレに行きたくなってしまった。
走って帰っても家まで持ちそうにない。

「トイレ…やだな…」

そう思いながらも、仕方なく3階の女子トイレに向かう。

トイレには3つの個室があった。
麻子は急いで手前の個室に入り、ホッと一息ついた。
用を済ませて手を洗いながらふと鏡を見ると、ふと背後に奇妙な違和感を覚えた。すると、、

ガンガンガン...!

急に扉を叩くような激しい物音がした。

一番奥の個室からだ。
どうしてだろう、麻子がここに入る前は、確かあの個室は空いていたはずなのに。

驚いた麻子は、しかしその正体を確認せずにはいられない。
ゆっくり、ゆっくり足を忍ばせながら、物音がする個室の方へと向かっていった。

すると、そこには...!!!!!!!!

card_label_img {
  margin-right: 7px;
  margin-left: 7px;
}
import { Icon } from "semantic-ui-react"
import attachmentIcon from "../assets/icon/attachment.svg"

const FearOfAsako = () => {
  return (
    <div className="card">
      <p className="card_label">
        <Icon name="info circle" />
        インフォメーション
      </p>
      <p className="card_label">
        <img className="card_label_img" src={attachmentIcon} />
        添付画像
      </p>
    </div>
  )
}

...こんなコードがあった。
なぜ誰もいないはずのトイレの個室にJavaScriptがあるのか、そんな謎もさる事ながら、
麻子はあまりの恐怖に、甲高い悲鳴をあげる。

そして、数分後。
やっとのことで平静を取り戻した麻子は、己の心に生じた恐怖を次のように分析する。

どうやらトイレの奥にいた人物は、card_label_imgCSSの部分で
imgの両端にmarginを持たせることによって、次の画像のような文字の垂直方向の位置ずれが生じるのを克服しようとしたらしい。

svg-invalid-align

だが、どう考えてもこれは変更に弱いコードである。

もし、デザイナーさんがアイコンのサイズの変更を依頼したら?
marginもまた画面を睨めっこしながら、微調整しないといけなくなってしまう。

それに「別の色に変えてほしい」と言われたら、別のsvg画像を用意する必要がありそうで、これも対応が面倒くさい。
さらに「ホバーしたときに色を変更してほしい」と言われたら...?
そのときはいよいよアイコンをimgで描画する方法だと対応しきれなくなりそうだ。

こんなコードで仕様変更に耐えられるだろうか...?
それを思うと、再び恐怖せずにはいられない麻子。
背筋には冷たい汗が走る..。
未だ廊下にはあの不気味な水の音が木霊する。
トイレの個室に取り残された彼女は、か細い声で独り、こう呟く。

ハヤクナントカシナケレバ...

ReactでSVGアイコンを扱う一番良い方法

もうすぐ夏も終わりということで、怪談からスタートしてみました。

先のコード、実は、前のプロジェクトに自分が残していってしまったコードです。
結局のところ、エンジニアにとって最も恐ろしい怪談は、過去に自分が書いたイケてないコード。
そういうことなのかもしれません(?)

今日は、そんな自分の過去の過ちを反省しながら、
ReactでSVGアイコンを扱うときの最良の方法に付いて考えていきたいと思います。

SVGアイコンを使う方法としては

  • imgタグのsrcに SVG ファイルのパスを指定する
  • import の仕方を工夫して、SVG をReactComponentに変換する
  • SVGRを使って SVG をReactComponentに変換する

などがあると思いますが、まず、このうちのどれが最も良い方法なのでしょうか?
順番に見ていきます。

1. imgタグのsrcに SVG ファイルのパスを指定する方法

コードで説明すると、次のような方法です。

import React from 'react';
import attachmentPin from '../assets/icon/attachment-pin.svg';

const Attachment = () => {
  return <img src={attachmentPin} />
}

この方法は広く使われている気がしますが、ぶっちゃけ、アンチパターンだと思います。

まず、imgタグを使うと、先ほどの麻子さんの分析通り、扱いがかなり面倒くさくなります。
デザインの仕様変更にも弱いです。
それだけではありません。
次の動画を見てください。

img-svg-problem

imgで SVG をレンダリングしている方が、わずかにレンダリングが遅くなり、チカっと一瞬だけレイアウトが崩れていることがわかります。
これはいただけませんね😡
本来ならスピーディーに描画できるはずの SVG も、imgのソースに指定すると遅くなってしまうので注意が必要です。

2. import の仕方を工夫して、SVG をReactComponentに変換する方法

次は、もうちょっと良い方法です。
コードは以下のようになります。

import { ReactComponent as AttachmentPinIcon } from "../assets/icon/attachment-pin.svg"

const Attachment = () => {
  return <AttachmentPinIcon  />
}

ReactComponent as 任意の名前という形で import すると、ReactComponentとして扱われます。
ちょっと裏技っぽい雰囲気もありますが、ちゃんとcreate-react-appの公式ドキュメントにも書かれている方法だったりします。

この方法の利点は、先ほどのimgを使って描画するのに比べて、格段にカスタマイズしやすいところです。
<svg>タグのattributesがpropsとして指定可能なので、次のように色やサイズを変更し放題です。

import { ReactComponent as AttachmentPinIcon } from "../assets/icon/attachment-pin.svg"

const Attachment = () => {
  return <AttachmentPinIcon fill="red" width={20} height={20}  />
}

しかし、この方法の弱点は、create-react-app でしか使えないということです。
最近はViteNext.jsを使うことが一般的になりつつあると思うので、CRA に依存するのはできれば避けたいところです。

3. SVGR を使って SVG をReactComponentに変換する方法

そこで、おすすめしたいのが、SVGRというツールです。
SVGR の公式サイトにアクセスすると、初っ端から「React アプリケーションで SVG を利用するための最高のツール」的な説明が書いてあって、我々の求めていたものだ感があります。

手軽な SVGR の導入方法は、下記のSVGR praygroundを使って、Web上でReactComponentに変換してしまうことです。

リンクに飛んでいただいて、こういうの↓を左側のSVG INPUTのところに貼ると、

<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 397 511.92"><path fill-rule="nonzero" d="M370.42 212.78c4.94-6.51 14.23-7.78 20.73-2.85 6.51 4.94 7.78 14.23 2.84 20.73L216.08 464.63c-20.06 26.3-49.03 42.03-79.53 46.18-30.03 4.09-61.64-3.04-87.78-22.38a15.67 15.67 0 0 1-2.56-1.94c-25.65-20.04-41.01-48.64-45.1-78.71-4.09-30.05 3.06-61.66 22.39-87.79.53-.88 1.16-1.71 1.86-2.47L239.33 36.15c16.39-19.23 34.57-31.3 54.79-34.97 20.41-3.71 41.94 1.25 64.75 16.18l.97.69.26.2.03.02c10.88 8.4 19.01 17.76 24.58 27.84 5.98 10.85 8.96 22.5 9.17 34.68.27 16.39-3.62 30.03-9.87 42.56-5.75 11.55-13.57 22.01-21.92 32.99l-198.2 260.67c-8.38 11.02-20.48 17.61-33.2 19.34-12.16 1.66-24.98-1.14-35.71-8.75-.96-.57-1.86-1.25-2.69-2.05-10.23-8.32-16.36-19.95-18.03-32.15-1.71-12.69 1.4-26.09 9.76-37.09L255.26 131.1c4.93-6.5 14.22-7.77 20.73-2.84 6.5 4.94 7.77 14.23 2.84 20.73L107.59 374.2c-3.4 4.48-4.66 10-3.95 15.26.71 5.22 3.4 10.17 7.86 13.56l.05.05c4.46 3.36 9.96 4.61 15.2 3.9 5.23-.71 10.18-3.39 13.57-7.85l198.2-260.67c7.26-9.55 14.07-18.66 18.9-28.34 4.33-8.68 7.02-17.98 6.85-28.86-.12-7.25-1.94-14.25-5.57-20.85-3.56-6.45-8.94-12.61-16.3-18.34-16.01-10.43-30.3-14.04-43.06-11.73-13.02 2.37-25.5 11.03-37.5 25.07L48.04 336.59c-15.1 19.85-20.69 44.13-17.55 67.24 3.08 22.65 14.58 44.16 33.77 59.22.75.46 1.47 1 2.14 1.62 19.67 14.5 43.51 19.85 66.21 16.76 22.67-3.08 44.19-14.61 59.24-33.82.48-.76 1.03-1.48 1.65-2.17l176.92-232.66z" /></svg>

こういうの↓が右側のJSX OUTPUTに出力されます。

import * as React from "react"
import { SVGProps } from "react"

const SvgComponent = (props: SVGProps<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    shapeRendering="geometricPrecision"
    textRendering="geometricPrecision"
    imageRendering="optimizeQuality"
    fillRule="evenodd"
    clipRule="evenodd"
    viewBox="0 0 397 511.92"
    width="1em"
    height="1em"
    {...props}
  >
    <path
      fillRule="nonzero"
      d="M370.42 212.78c4.94-6.51 14.23-7.78 20.73-2.85 6.51 4.94 7.78 14.23 2.84 20.73L216.08 464.63c-20.06 26.3-49.03 42.03-79.53 46.18-30.03 4.09-61.64-3.04-87.78-22.38a15.67 15.67 0 0 1-2.56-1.94c-25.65-20.04-41.01-48.64-45.1-78.71-4.09-30.05 3.06-61.66 22.39-87.79.53-.88 1.16-1.71 1.86-2.47L239.33 36.15c16.39-19.23 34.57-31.3 54.79-34.97 20.41-3.71 41.94 1.25 64.75 16.18l.97.69.26.2.03.02c10.88 8.4 19.01 17.76 24.58 27.84 5.98 10.85 8.96 22.5 9.17 34.68.27 16.39-3.62 30.03-9.87 42.56-5.75 11.55-13.57 22.01-21.92 32.99l-198.2 260.67c-8.38 11.02-20.48 17.61-33.2 19.34-12.16 1.66-24.98-1.14-35.71-8.75-.96-.57-1.86-1.25-2.69-2.05-10.23-8.32-16.36-19.95-18.03-32.15-1.71-12.69 1.4-26.09 9.76-37.09L255.26 131.1c4.93-6.5 14.22-7.77 20.73-2.84 6.5 4.94 7.77 14.23 2.84 20.73L107.59 374.2c-3.4 4.48-4.66 10-3.95 15.26.71 5.22 3.4 10.17 7.86 13.56l.05.05c4.46 3.36 9.96 4.61 15.2 3.9 5.23-.71 10.18-3.39 13.57-7.85l198.2-260.67c7.26-9.55 14.07-18.66 18.9-28.34 4.33-8.68 7.02-17.98 6.85-28.86-.12-7.25-1.94-14.25-5.57-20.85-3.56-6.45-8.94-12.61-16.3-18.34-16.01-10.43-30.3-14.04-43.06-11.73-13.02 2.37-25.5 11.03-37.5 25.07L48.04 336.59c-15.1 19.85-20.69 44.13-17.55 67.24 3.08 22.65 14.58 44.16 33.77 59.22.75.46 1.47 1 2.14 1.62 19.67 14.5 43.51 19.85 66.21 16.76 22.67-3.08 44.19-14.61 59.24-33.82.48-.76 1.03-1.48 1.65-2.17l176.92-232.66z"
    />
  </svg>
)

export default SvgComponent

あとはこれを普通に使うだけです!
ReactComponentなので、2の方法と同じように、非常に扱いやすくなっています。
それに加えて、ViteでもNext.jsでも何でもちゃんと動作します。

import AttachmentPinIcon from "../svgr-icons/attachment-pin"

const Attachment = () => {
  return <AttachmentPinIcon fill="red" width={20} height={20}  />
}

SVGRは、SVG の最適化もよしなにやってくれるらしいです。
特に、PhotoShop 等で作られたアイコンの場合、最適化しないとパフォーマンスに悪影響が出る可能性があります。

また、Web上に貼りつけて変換して、というのを毎回やるのも面倒なので、
プラグインを使って自動化することも可能です。
この方法については、Viteの場合、以下の記事に詳しいです。

Vite + React(typescript) で SVGファイルをコンポーネントとして読み込む方法 by @piyokoさん

Next.jsの場合、以下の記事に詳しいです。

Next.jsでSVGをコンポーネントとしてImportするやり方 by @manak1さん
Next.js + TypeScriptでimportしたSVGの型がanyになってしまう by @catnoseさん

これで SVG アイコンの使い方はバッチリです!!

使用感をアイコンのライブラリと揃える

ReactでSVGアイコンを扱うための最良の方法はSVGRだという結論に至ったところで、
最後にちょっとしたテクニックを紹介させてください。

まず、こんなコンポーネントがあるとします。

InformationCard.jsx
import { Icon } from "semantic-ui-react"
import attachmentIcon from "../assets/icon/attachment.svg"
import { AttachmentPin } from '../components/icons';
import { MdOutlineAttachFile } from 'react-icons/md';

const InformationCard = () => {
  return (
    <div className="card">
      <p className="card_label">
        <MdOutlineAttachFile />
        インフォメーション
      </p>
      <p className="card_label">
        <AttachmentPin />
        添付画像
      </p>
    </div>
  )
}

これは「添付画像のアイコン(AttachmentPin)にについては、react-iconsに目ぼしいアイコンがなかったので、デザインーさんが作ってくれた自前のアイコンを SVGR でReactComponentに変換して使用した」というケースです。
これをカスタマイズすると、次のようになります。

InformationCard.jsx
import { AttachmentPin } from '../components/icons';
import { MdOutlineAttachFile } from 'react-icons/md';
import { useTheme } from '@emotion/react'

const InformationCard = () => {
  const theme = useTheme()

  return (
    <div className="card">
      <p className="card_label">
        <MdOutlineAttachFile 
          color={theme.palette.primary.main}
          fontSize={theme.size.md}
        />
        インフォメーション
      </Typography>
      <p className="card_label">
        <AttachmentPin
          fill={theme.palette.primary.main} 
          height={theme.size.md}
          width={theme.size.md}
        />
        添付画像
      </p>
    </div>
  )
}

MdOutlineAttachFilecolorで色を変更しているのに対し、
AttachmentPinfillでサイズを変更しています。

また、MdOutlineAttachFilefontSizeでサイズを変更しているのに対し、
AttachmentPinheightwidthでサイズを変更しています。

微妙に使用感が異なっていて、これは使い勝手が悪そうですね。
しかし、これを仮に、

InformationCard.jsx
const Example = () => {
  return (
    {/* 省略 */}
    <AttachmentPin
      color={theme.palette.primary.main}
      fontSize={theme.size.md}
    />
    {/* 省略 */}
  )
}

としたとしても、色もサイズも変わりません。
なんともややこしい。。。ミスが発生しそうです。

これは SVG の仕様なので仕方のないことです。
でも、どうにかして、この使用感の違いを乗り越えることはできないのでしょうか。

ということで、以下のようなコンポーネントを作ってみました。

import { css, cx } from '@emotion/css';
import { useTheme } from '@emotion/react'

const styledIcon = ({
  fontSize,
  color,
}) =>
  css({
    fontSize,
    color,
  });

export const SvgIcon = (props) => {
  const { Icon, color = 'inherit', fontSize = 'inherit', ...rest } = props;
  const theme = useTheme();

  const themeColor = {
    inherit: 'inherit',
    action: theme.palette.action.active,
    disabled: theme.palette.action.disabled,
    primary: theme.palette.primary.main,
    secondary: theme.palette.secondary.main,
    error: theme.palette.error.main,
    info: theme.palette.info.main,
    success: theme.palette.success.main,
  }[color];

  const themeFontSize = {
    inherit: 'inherit',
    sm: 20,
    md: 24,
    lg: 35,
  }[fontSize];

  return (
    <Icon
      className={styledIcon({ fontSize: themeFontSize, color: themeColor })}
      color={themeColor}
      fontSize={fontSize}
      {...rest}
    />
  );
});

これを使うと、次のように、react-iconsのアイコンと自前で用意したアイコンの差異を全く意識することなく扱うことができます。

import { AttachmentPin } from '../components/icons';
import { MdOutlineAttachFile } from 'react-icons/md';
import { useTheme } from '@emotion/react'
import { SvgIcon } from '../components/ui/SvgIcon'

const InformationCard = () => {
  return (
    <div className="card">
      <p className="card_label">
        <SvgIcon Icon={MdOutlineAttachFile} fontSize="md" color="primary" />
        インフォメーション
      </p>
      <p className="card_label">
        <SvgIcon Icon={AttachmentPin} fontSize="md" color="primary" />
        添付画像
      </p>
    </div>
  )
}

色やフォントも自在に簡単に変えられます。

import { AttachmentPin } from '../components/icons';
import { MdOutlineAttachFile } from 'react-icons/md';
import { useTheme } from '@emotion/react'
import { SvgIcon } from '../components/ui/SvgIcon'

const Example = () => {
  return (
    <div>
      <SvgIcon Icon={AttachmentPin} fontSize="sm" color="action" />
      <SvgIcon Icon={AttachmentPin} fontSize="sm" color="disabled" />
      <SvgIcon Icon={AttachmentPin} fontSize="md" color="primary" />
      <SvgIcon Icon={AttachmentPin} fontSize="md" color="secondary" />
      <SvgIcon Icon={AttachmentPin} fontSize="lg" color="error" />
      <SvgIcon Icon={AttachmentPin} fontSize="lg" color="info" />
      <SvgIcon Icon={AttachmentPin} fontSize="lg" color="success" />
      <SvgIcon Icon={MdOutlineAttachFile} fontSize="sm" color="action" />
      <SvgIcon Icon={MdOutlineAttachFile} fontSize="sm" color="disabled" />
      <SvgIcon Icon={MdOutlineAttachFile} fontSize="md" color="primary" />
      <SvgIcon Icon={MdOutlineAttachFile} fontSize="md" color="secondary" />
      <SvgIcon Icon={MdOutlineAttachFile} fontSize="lg" color="error" />
      <SvgIcon Icon={MdOutlineAttachFile} fontSize="lg" color="info" />
      <SvgIcon Icon={MdOutlineAttachFile} fontSize="lg" color="success" />
    </div>
  )
}

svg-ideal-example

コードもすっきりして、悪くない気がします。
ちゃんとTypeScript化したコードをこちらに置いておきましたので、よかったら暇なときに覗いてみてください。

補足:色がうまく変わらないとき

今回紹介したような方法でも、色がうまく変わらないときがあるかもしれません。

そういうケースでは、SVG の作られ方自体に問題があり、直接手を加える必要がありそうです。
具体的には、

stamp.svg
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="path(省略)" fill="black"/>
</svg>

のようになっている場合、

stamp.svg
<svg width="16" height="16" viewBox="0 0 16 16" fill="black" xmlns="http://www.w3.org/2000/svg">
<path d="path(省略)"/>
</svg>

のように、<path>の方ではなく<svg>の方で色を管理するようにしてあげると、

const Example = () => {
  return <SvgIcon Icon={Stamp} fontSize="sm" color="error" />
}

↑のような形でちゃんと色が変えられるようになります。
やや面倒ですが、色のバリエーションごとに画像を用意するよりは手間が少ないと思うので、ぜひ試してみてください。

最後は結局、デザイナーさんに頼る

ここまでの内容で大分 SVG のアイコンを扱いやすくできたと思うのですが、
そういえばまだ麻子さんを恐怖に陥れた冒頭の CSS の話が解決していませんでした。

card_label_img {
  margin-right: 7px;
  margin-left: 7px;
}

なぜこんなチマチマと間隔を調整する CSS が必要なのかというと、
それは添付画像のアイコン(AttachmentPin)だけが元々、縦長のシェイプを持っていたからです。
画像で説明すると、つまり、こういう↓ことです。

different-shape

この問題は、アイコンをできるだけ正方形に統一してほしい、とデザイナーさんに頼めばよしなに解決してくれると思います。
もし、自分でやるなら、下記のようにサイズ調整用の見えない正方形を被せてやると、ええ感じの SVG が生成できるようになります。

figma-rect-adjust

このように加工してから吐き出された SVG は、何もしなくても他のアイコンとサイズが揃っているので、先ほどの奇怪な CSS はもう必要ありません。

まとめ

最良の SVG の扱う方法を検討しつつ、Figma を使ってレイアウトしやすい SVG を吐き出す方法も書いてみました。

「エンジニアなのに、デザインツールの扱い方を覚えるのは嫌だ」というのは確かにあるかもしれませんが、デザインとエンジニアリングの境界は、誰も拾わないまま、よくボールが落ちてしまう場所です。
だからこそ、フロントエンドエンジニアが『道の真ん中を掃く精神』でちょこっとだけ Figma を触ってみるのは悪くない選択かもしれません。

そこに誰も拾わないで転がっているボールがあるのなら、自分から拾いに行く精神が大事です。
狭間に落ちたボールを、デザイナーさんが拾ってくれるのを待っていて良いものでしょうか。否、エンジニアも積極的に拾っていくべきです。

せっかくなのでここは怪談風に記事を締めくくりたいです。

落ちたボールを拾うのは、、、

「『お前だーーーっ!』」

...。

...。

...結局、これがやりたかっただけという。

...以上です。最後まで茶番にお付き合いくださりありがとうございました🙇‍♂️

GitHubで編集を提案

Discussion