テキストとSVGアイコンの上下中央が揃わないのでvertical-alignのem/%を試す

テキストとSVGアイコンを横に並べたとき、vertical-align: middleを使っても「視覚的中心」が揃ってないことがあったりします。最初はflexを使っていて、結局em指定でベースラインを調整するにいたった検証をまとめます。

inline-flexからインライン戻しまで

ボタンの共通コンポーネントを作っていたとき、最初はこんな感じで実装しました。

.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

コンテナ内でアイコンもテキストも中央に出来て、gapで余白も簡単に作れるからです。ボタンが単行だったとしたらこれで問題はなかったと思います。

ですが、モバイル表示でCTAボタンの文言が2行に折り返されたときに崩れました。

<button class="btn">
  <svg class="btn-icon" viewBox="0 0 16 16" aria-hidden="true">...</svg>
  <span class="btn-text">利用規約に同意してアカウントを作成</span>
</button>

このときSVGは「2行分合わせた高さの中央」に配置されます。テキストは1行目と2行目に分かれているのに、アイコンだけがコンテナ全体の垂直中央に取り残される形に。インラインリンク内のテキストが改行された場合も同じで、画面幅によっては規約同意みたいな短いラベルでもすぐ壊れます。

align-items: flex-start;にしてみても、今度はアイコンの上端が1行目のテキスト上端に寄るだけで、やっぱり文字の視覚的中心と合ってない。なのでアイコンとテキストを同じインライン整形コンテキストに戻す必要がありました。

.btn {
  display: inline-block;
}

.btn-text {
  display: inline;
  font-size: 14px;
  line-height: 1.5;
}

.btn-icon {
  display: inline-block;
  width: 1em;
  height: 1em;
  fill: currentColor;
  vertical-align: middle;
}

ここからが本題でvertical-align: middleは思ってるより綺麗に揃わないのです。

スクリーンを凝視してみると、SVGがテキストに対して1pxか1.5pxくらい下に沈んでいるのが分かる。Cap-height(大文字の高さ)に対してアイコンの重心が落ちていて、明らかに中央寄りではなく下寄りになっています。

vertical-alignのキーワードでは視覚的中心に行かない

CSS仕様を確認すると、vertical-align: middleは「親要素のベースラインに親要素のx-heightの半分を加えた位置」へ、要素の垂直中心を合わせる、となってます。

middle = parent baseline + parent x-height / 2

InterやNoto Sans JPみたいな一般フォントだと、x-heightはfont-sizeの0.5em前後になります。一方でCap-height(大文字)や日本語グリフは仮想ボディの中央付近に配置されるので、視覚的中心はx-heightの中心よりも上になる。つまりmiddleは「x-heightの中心寄り」に揃える指定であって、大文字や日本語の視覚的中心と一致しない。1em四方のSVGをこれに合わせると、下側に寄るのは仕様通りということになります。

キーワード値も一通り試してみました。

vertical-align: top;
vertical-align: text-top;
vertical-align: middle;
vertical-align: baseline;
vertical-align: text-bottom;
vertical-align: bottom;

text-topはSVGの上端が仮想ボディの上端にくっつき、アイコンが浮きすぎる。逆にtext-bottomは下端にくっついて沈みすぎる。baselineはSVGの底辺がテキストのベースラインに一致するので、1em角のアイコンではほぼ論外です。

topと bottomは行ボックスに依存するので、line-heightが変わると位置も変わります。コンポーネントとして固定させたいので不採用。

場当たり的にこうも考えました。

.btn-icon {
  position: relative;
  top: -1px;
}

14pxのボタンだけで見れば誤魔化せるけど、コンポーネントとしては脆くなる。font-sizeがsm、md、lgで変わると、-1pxの意味が変わってしまうからです。12pxでは効きすぎで、24pxでは足りなくなる。その場しのぎになるのでやめました。

ベースラインからのemオフセットを逆算する

vertical-alignの <length>と <percentage>を使って、ベースラインからのオフセットを直接コントロールすることにしました。

まずはBootstrap 5の.biアイコンなどで見かける値でテスト。

.inline-icon {
  vertical-align: -0.125em;
}

font-sizeが16pxなら-2px、14pxなら-1.75pxになります。これだけでかなりマシになりますが、自分のプロジェクトでは日本語テキストが混在してるので、もう少し詰めます。

フォント・メトリックから逆算してみました。Interの場合、だいたいこんな感じです。

UPM        = 2048
Cap Height = 1490  → 約 0.728em
x-Height   = 1080  → 約 0.527em
Ascender   = 1984
Descender  = -494

SVGをwidth: 1em; height: 1em;のインラインブロックとして置くと、今回の構成では底辺がベースラインに来ます。つまりvertical-align: baselineなら、SVGの垂直中心はベースラインから +0.5emの位置にあるわけです。

一方でCap-height基準でテキストの視覚的中心を取ると、ベースラインから +0.364em付近になります。

Cap-height 中心 = 0.728em / 2 = 0.364em
SVG 中心        = 0.5em
必要オフセット  = 0.364em - 0.5em = -0.136em

Noto Sans JPも似たようなもので、Cap-heightは約0.73em前後。漢字の視覚的中心はベースラインから +0.35emから+0.38em付近に収まります。この範囲で計算すると、オフセットは約-0.12emから-0.15emになる。

一旦こんな感じで決めました。

.inline-icon {
  display: inline-block;
  width: 1em;
  height: 1em;
  fill: currentColor;
  vertical-align: -0.14em;
  overflow: visible;
}

overflow: visibleは必須じゃなく、アイコンによってはパスのはみ出しやストロークの末端が切れにくくなるための保険のようなものです。

各サイズで確認するために、こんな簡易ページを作って検証しました。

<p style="font-size: 12px; line-height: 1.4;">
  <svg class="inline-icon" viewBox="0 0 16 16" aria-hidden="true">
    <path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" fill="none"/>
  </svg>
  詳細を見る
</p>
<!-- 12px * -0.14 = -1.68px -->

<p style="font-size: 16px; line-height: 1.5;">
  <svg class="inline-icon" viewBox="0 0 16 16" aria-hidden="true">
    <path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" fill="none"/>
  </svg>
  詳細を見る
</p>
<!-- 16px * -0.14 = -2.24px -->

<h2 style="font-size: 24px; line-height: 1.3;">
  <svg class="inline-icon" viewBox="0 0 16 16" aria-hidden="true">
    <path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" fill="none"/>
  </svg>
  詳細を見る
</h2>
<!-- 24px * -0.14 = -3.36px -->

Blink、WebKit、Geckoあたりで見た感じ、12pxから32pxくらいの範囲では-0.14emがそれなりに安定しています。Cap-heightとアイコンの視覚的中心のズレは、MacのRetina表示では許容範囲。

だけどDPR 1の外部ディスプレイ、特にWindows環境では小数pxの丸めが効いてきます。-1.68pxが-1pxや-2pxになったり、1pxのガタが気づかれやすい。Blink側でレイアウト単位で丸められ、デバイスピクセルにスナップされるからです。ここはCSSだけでは対処できません。

viewBoxの余白とアイコンセット差が効いてくる

もう一つ厄介だったのが、SVG側のviewBoxの余白です。

プロジェクト内で複数のアイコンセットが混在していたのが原因でした。

  • Lucide / Feather: viewBox="0 0 24 24"だが、内部描画領域は2px余白を取った実質20x20になっているものがある。
  • デザイナーがFigmaから直接書き出したSVG: pathのバウンディングボックスがviewBoxの端まで接地している、いわゆるtight bounds。

理論上、上下に均等な余白がある限り、描画領域の幾何中心は変わらないはず。viewBox内で上下に2pxずつ余白があっても、中心は常に12/24の位置にあって、-0.14emを当てても、ボックス中心としてはずれないはずなんです。

でも実際には、tight bounds用に決めた -0.14em がそのまま合うとは限りません。原因がいくつかあります。

  • ストロークの端の形状や線幅のせいで、視覚的中心が幾何中心と合ってない。
  • アイコン自体のデザイン(形や偏り)のせいで、視覚的中心が幾何中心と合ってない。
  • Figmaから書き出すときに、pathのバウンディングボックスがクリップされてる。
  • 上下の余白がぴったり均等じゃないアイコンが混ざってる。

特に上下余白が非対称な場合は、素直に補正を入れたほうが早いです。補正量はだいたい次の考え方で出せます。

補正量 = (上余白 - 下余白) / (2 × viewBoxサイズ) em
Offset = -0.14em + 補正量

例えば viewBox="0 0 24 24" で上余白3、下余白1なら、

補正量 = (3 - 1) / (2 × 24) = 2 / 48 = 0.0417em
Offset = -0.14em + 0.0417em = -0.0983em

となる。上側の余白が大きい場合、描画領域に対してアイコンが下に寄るので、アイコンを少し持ち上げる補正をしてます。

逆に上下均等なら補正量は0。にもかかわらず体感でずれて見える場合は、vertical-align だけで何とかしようとするより、SVG側のviewBoxかpath自体を調整した方が確実です。

%はline-heightに引っ張られるのでemに寄せる

%指定も試していましたが、アイコンのfont-sizeスケール追従には向きませんでした。

vertical-alignのパーセンテージは、要素自身のline-heightに対する割合として計算されます。要素に特にline-heightを指定してなければ、親の値をそのまま継承することになります。

.parent {
  font-size: 14px;
  line-height: 21px; /* 1.5 */
}

.inline-icon {
  vertical-align: -10%;
}

この場合、21px * -0.10 = -2.1pxに。

厄介なのが親の行間が動くとオフセットの絶対値も一緒に動いちゃうこと。Tailwindのleading-tightやleading-noneが当たってline-heightが急に縮むと、%指定したアイコンの位置もベースラインごと上に持ち上がります。

line-height: 1.5 → 14px * 1.5 = 21px → -10% = -2.1px
line-height: 1.2 → 14px * 1.2 = 16.8px → -10% = -1.68px
line-height: 1.0 → 14px * 1.0 = 14px    → -10% = -1.4px

行のリズムに合わせてアイコンを動かしたいなら%も選択肢になります。だけどボタンやインラインリンクのコンポーネントとして再利用することを考えると、アイコンは自分のfont-sizeに追従してもらいたいので、em指定一択になりました。

当面は-0.14emを既定値にし、例外だけCSS変数で逃がす

最終的なCSSは、こんな形になっています。

.inline-icon {
  display: inline-block;
  width: 1em;
  height: 1em;
  fill: currentColor;
  vertical-align: -0.14em;
  overflow: visible;
}

ボタン内で使うときは、flexのgapが効かなくなるので、余白もemで持たせます。

.btn-icon {
  margin-right: 0.35em;
}

この値もフォントサイズにスケールするので、14pxなら約4.9px、16pxなら5.6pxになるので、固定pxよりは自然な感じです。

だけどまだ完全に一本化できているわけではありません。

macOS / iOSの-apple-system、SF Pro、ヒラギノ角ゴと、WindowsのSegoe UI、メイリオでは、ベースラインの位置やUPM設計が微妙に違っています。WindowsのChromeで見ると、Macに比べてSVGが0.5pxくらい浮いて見えることがあるのです。

グローバルに-0.14emを当てることで大半はカバーできると思いますが、アイコンセット側のviewBox余白も統一されていないので、セットごとに微調整が必要になります。

今はこうやってCSS変数を外に出しておいて、コンポーネント側から上書きできる形にしてます。

.inline-icon {
  --icon-offset: -0.14em;
  vertical-align: var(--icon-offset);
}

.icon-set-padded {
  --icon-offset: -0.06em;
}

理想で言えば、アイコンセット側でviewBox内の余白を揃えるか、 tight boundsで書き出すルールに統一させたいです。今あるアイコンや古い素材がいろいろ混在している限り、CSS側ででなんとかするしかないんですけど、フォントやディスプレイ解像度によってどうしてもブレてしまいます。

とりあえず -0.14emは、日本語テキストと1em SVGをインラインで並べるときの、今の落としどころです。完全に中央揃えを目指すより、ベースラインに対して視覚的に違和感が少ない位置、ということでその数値に行き着きました。

今後やるなら、アイコンセットごとに--icon-offsetのデフォルトを持たせるか、SVG側で余白をきれいに揃えてもらうほうが根本的な解決になります。CSSハックだけでどうにかしようとすると、フォントサイズ、line-height、DPR、viewBox余白、OSフォント差がすべて絡んでくる。そこは割り切って、既定値は-0.14emとしながら、例外は変数で上書きするというルールで回してます。