CSS で縁取り文字を表現するのに、まず考えられるのは -webkit-text-stroke を使用する方法で、以下のようなコードになります。

.text {
  -webkit-text-stroke: 1px #000;
}

ここで、color: transparent を指定すると塗り色が透明になり、縁取りの罫線のみでテキストを表示することができます。

.text {
  color: transparent;
  -webkit-text-stroke: 1px #000;
}

以下は、フォントファミリーに「Verdana」を指定した例です。

「Verdana」を指定したテキストに `-webkit-text-stroke` を指定した状態のスクリーンショット
-webkit-text-stroke で縁取り文字を実現できる

-webkit-text-stroke とバリアブルフォントの問題

見出し「-webkit-text-stroke とバリアブルフォントの問題」

この -webkit-text-stroke ですが、さきほどの塗りを透明にした状態(color: transparent)でバリアブルフォント(Variable Fonts)を指定すると、文字の内側まで線が引かれてしまう問題があります。

例えば、Google フォントで人気の高い「Montserrat」や「Noto Sans Japanese」はバリアブルフォントですが、-webkit-text-stroke を使用すると以下のような見え方になります。

「Montserrat」と「Noto Sans Japanese」を指定したテキストに `-webkit-text-stroke` を指定した状態のスクリーンショット
バリアブルフォントでは、文字の内側まで線が引かれてしまう

以下は該当する GitHub Issue です。

GitHub Issue でコメントされていますが、この問題の原因は Google フォント側ではなく、ウェブブラウザのバグもしくは制約にあるようです。

またこの問題は、ウェブブラウザにとどまらずデザインツールも対象です。ただし、ツールによって差があり、Figma では問題なく表示されますが、Adobe XD や Affinity Designer では同様の現象が発生するようです。

裏を返せば、Adobe XD や Affinity Designer であればデザイン時にこの問題に気付きますが、Figma を使った場合には、コーディングの段階まで気づかない可能性があるといえます。

この問題を解消する代替案として、以下が考えられます。

テキストを画像化する方法では確実に問題を解消できますが、メンテナンス性やパフォーマンス(ファイルサイズ)、アクセシビリティ、テキスト操作(コピーなど)ができなくなることとのトレードオフになります。

CSS の box-shadow プロパティを使用

見出し「CSS の 」

box-shadow でも縁取り文字を実現することができますが、テキストを透明にすることができないため、利用ケースは限定されます。

また、塗りがある状態でも、box-shadow を使用してなめらかな縁取りを表現するにはコードが複雑になるので、メンテナンス性を犠牲にすることになります。加えて、文字の形やフォントサイズ、罫線のサイズによっては、きれいな縁取りがされないことがあります。

SVG フィルタを使用して、縁取り文字を実現する方法です。CSS の filter プロパティを使用することで、HTML 要素にも SVG フィルタを適用できます。

本記事では、この SVG フィルタを使用する方法で考えていきます。

SVG には feMorphology というフィルタがあります。このフィルタを使用すると、対象となるソース(テキストなど)を太くしたり細くする効果が得られます。

feMorphology では、以下の属性を指定できます。

属性内容
in入力名。指定がない場合には SourceGraphic(元のグラフィック要素)または、直前のフィルタの出力結果が対象となる
operatorフィルタ効果のタイプ。dilate(膨張)または erode(侵食)
radius半径の値。値が 1 つの場合には XY 軸をまとめて指定。値が 2 つの場合には X 軸と Y 軸それぞれを指定
result出力名。指定がない場合にはフィルタの最終結果になるか、次のフィルタの入力値になる

operator 属性で、dilateerode のいずれかのフィルタの効果を指定できます。文字を縁取るケースではどちらも使用できますが、見た目の最終結果が異なります。

`feMorphology` のフィルタの効果を表した図。`operator` に `dilate` を指定すると文字が太くなり、`erode` を指定すると文字が細くなる

上図ではデザインツール上で太さを調整していますが、実際にフィルタを適用したときには、輪郭の精細さがもう少し低くなります。

以下は、HTML のテキスト要素に対して、dilateerode をそれぞれ指定した例です。

dilate と erode を使用した例
<p>Lorem ipsum</p>
<p class="dilate">Lorem ipsum</p>
<p class="erode">Lorem ipsum</p>

<svg aria-hidden="true">
  <filter id="dilate">
    <feMorphology operator="dilate" radius="1" result="DILATED"></feMorphology>
  </filter>
  <filter id="erode">
    <feMorphology operator="erode" radius="1" result="ERODED"></feMorphology>
  </filter>
</svg>

<style>
p {
  font-family: Verdana, sans-serif;
  font-size: 4rem;
  font-weight: bold;
}
.dilate {
  filter: url(#dilate);
}
.erode {
  filter: url(#erode);
}
</style>

svg 要素内で filter 要素を指定して id 属性を付与しておき、CSS の filter: url(#id) でフィルタを適用しています。

結果は以下のようになります。

Live Demo
default

Lorem ipsum

dilate

Lorem ipsum

erode

Lorem ipsum

このように、feMorphology を使用することで、文字を太くしたり(dilate)、細くしたり(erode)できますが、この状態に feComposite を組み合わせて文字の縁取りを実現します。

feComposite では、2 つの要素を合成するフィルタです。指定できる属性は以下のとおりです。

属性内容
in入力名。指定がない場合には SourceGraphic(元画像)または、直前のフィルタの出力結果が対象となる
in2in と合成する入力名
operator合成方法。Porter-Duff Compositing をベースとした 6 つのキーワードもしくは arithmetic を指定
result出力名。指定がない場合にはフィルタの最終結果になるか、次のフィルタの入力値になる

operator で使用できる 6 つのキーワードは overinoutatopxorlighter で、デフォルト値は over です。

`operator` の 6 つのキーワードを指定したときのそれぞれの見え方を表した図

この記事のサンプルでは、outxor をそれぞれ使用します。

SVG フィルタで縁取り文字を実現するには、feMorphologyoperator="dilate" を指定する方法と、operator="erode" を指定する方法が考えられます。

まずは operator="dilate" を指定する方法を見ていきます。

dilate を使用した例
<p class="dilate">
  Lorem ipsum
</p>

<svg aria-hidden="true">
  <filter id="dilate">
    <feMorphology operator="dilate" radius="1" result="DILATED"></feMorphology>
    <feComposite in="DILATED" in2="SourceGraphic" operator="out"></feComposite>
  </filter>
</svg>

<style>
p {
  font-family: Verdana, sans-serif;
  font-size: 4rem;
  font-weight: bold;
}
.dilate {
  filter: url(#dilate);
}
</style>

dilate を指定することで、radius の分だけ外側に膨張します。

加えて feComposite で直前のフィルタの結果を in、元のテキストを in2 とし、operator="out" を指定することで、重なった元のテキスト部分が取り除かれます。

`feComposite` のフィルタの効果を表した図。`operator="out"` を指定することで、`in` に重なった `in2` の部分が取り除かれる

その結果、以下のように縁取り文字を実現することができます。

Live Demo
dilate

Lorem ipsum

次に、feMorphologyoperator="erode" を指定する方法を見ていきます。

erode を使用した例
<p class="erode">
  Lorem ipsum
</p>

<svg aria-hidden="true">
  <filter id="erode">
    <feMorphology operator="erode" radius="1" result="ERODED"></feMorphology>
    <feComposite in="ERODED" in2="SourceGraphic" operator="xor"></feComposite>
  </filter>
</svg>

<style>
p {
  font-family: Verdana, sans-serif;
  font-size: 4rem;
  font-weight: bold;
}
.erode {
  filter: url(#erode);
}
</style>

erode を指定することで、radius の分だけ内側に侵食します。

さきほどの応用ですが、feComposite で直前のフィルタの結果を in、元のテキストを in2 とし、operator="xor" を指定することで、2 つのソースが重ならない部分が結合されます。

`feComposite` のフィルタの効果を表した図。`operator="xor"` を指定することで、`in` と `in2` の重ならない部分が結合される

こちらでも、縁取り文字を実現することができます。

Live Demo
erode

Lorem ipsum

dilateerode の結果を比較すると、フィルタ効果の違いにより、文字の太さが異なりますが、-webkit-text-stroke で実装した場合と見え方が近いのは erode です。

Live Demo
dilate

Lorem ipsum

erode

Lorem ipsum

-webkit-text-stroke

Lorem ipsum

しかし、次項で説明しますが、Safari の描画上のバグを踏まえると、erode ではなく dilate を採用するのが無難なように思えます。

そもそも、-webkit-text-stroke の問題を解消するために SVG フィルタを取り入れましたが、この SVG フィルタを使用した方法にもいくつか欠点があります。

上記の SVG フィルタを使用した方法では、Safari にて描画上のバグがあるようです。

1. 斜体(italic)のバグ

見出し「1. 斜体(」

font-style: italic のように斜体のスタイルを指定すると、最後の文字の右上が切り取られることがあります。

`-webkit-text-stroke`、`dilate`、`erode` それぞれの指定で縁取り文字を実装した状態を macOS Safari で表示したときのスクリーンショット
macOS Safari で見ると、dilateerode の指定では最後の「局」の右上部分が切り取られている

2. erode のバグ

見出し「2. 」

こちらは発生する条件がわからないのですが、erode でフィルタをかけたときに謎の線が引かれることがあります。dilate を使用したときにはこの現象は見受けられませんでした。

``erode` で縁取り文字を実装した状態を macOS Safari で表示したときのスクリーンショット
macOS Safari で見ると、erode の指定で文字の内側に線が入ることがある

そのほかにも、iOS、macOS ともに、Safari での SVG フィルタの描画は若干不安定なようで、dilateerode ともに文字の一部が切り取られるような現象が起きることがありました。

以下のように、ホバー時に縁取りから文字が塗られた状態にアニメーション(transition)をともなう変化をつけたい場合には工夫が必要になります。

Live Demo
ホバー効果(アニメーションなし)

Lorem ipsum

ホバー効果(アニメーションあり)

Lorem ipsum

まず、アニメーション(transition)をつけない場合には、以下のコードで実現できます。

ホバー効果の例
<p>
  <a href="#" class="dilate">
    Lorem ipsum
  </a>
</p>

<svg aria-hidden="true">
  <filter id="dilate">
    <feMorphology operator="dilate" radius="1" result="DILATED"></feMorphology>
    <feComposite in="DILATED" in2="SourceGraphic" operator="out"></feComposite>
  </filter>
</svg>

<style>
p {
  font-family: Verdana, sans-serif;
  font-size: 4rem;
  font-weight: bold;
}
.dilate {
  filter: url(#dilate);
}
.dilate:hover {
  filter: none;
}
</style>

このように、単純にホバー時に filter: none を指定するだけです。

対して、アニメーション(transition)をつける場合には若干複雑になります。

ホバー効果にアニメーションをつけた例
<p>
  <a href="#" class="dilate" aria-label="Lorem ipsum">
    Lorem ipsum
  </a>
</p>

<svg aria-hidden="true">
  <filter id="dilate">
    <feMorphology operator="dilate" radius="1" result="DILATED"></feMorphology>
    <feComposite in="DILATED" in2="SourceGraphic" operator="out"></feComposite>
  </filter>
</svg>

<style>
p {
  font-family: Verdana, sans-serif;
  font-size: 4rem;
  font-weight: bold;
}
.dilate {
  display: inline-block;
  position: relative;
  color: transparent;
}
.dilate::before,
.dilate::after {
  content: attr(aria-label);
  position: absolute;
  top: 0;
  left: 0;
  color: #000;
}
.dilate::before {
  filter: url(#dilate);
}
.dilate::after {
  opacity: 0;
  transition: opacity 0.4s ease;
}
.dilate:hover::after {
  opacity: 1;
}
</style>

元のテキストは color: transparent で透明にしておきます。

::before 擬似要素、::after 擬似要素に、aria-label で指定したテキストを表示して、position: absolute で元のテキストの上に重ねます。

::before には、filter: url(#dilate) で縁取り文字のスタイルを指定しており、::after は初期状態で透明(opacity: 0)にしておき、ホバー時にアニメーションをともなって表示するようにしています。

ちなみに aria-label 属性にしているのは、Google 翻訳などの機械翻訳を使用したときに翻訳の対象にするためです。しかし、場合によっては aria-label が翻訳の対象にならないこともあるので 1、この点においても課題を残します。

そのため、このように擬似要素を使用するのではなく、2 つの要素(<span> など)に同じテキストを指定し、一方に aria-hidden="true" を指定したほうが確実かもしれません。

ページ内で id 属性の値はユニークである必要があるので、<filter> に指定する id が重複しないように注意が必要です。

フィルタのためだけに用意した SVG 要素がサイズを持ってしまい、周囲のレイアウトに影響を及ぼすので、CSS でサイズを無効にする必要があります。

また、スクリーンリーダーに認識させないために aria-hidden="true" を指定します。

SVG 要素の最適化の例
<p class="dilate">
  Lorem ipsum
</p>

<svg aria-hidden="true">
  <filter id="dilate">
    <feMorphology operator="dilate" radius="1" result="DILATED"></feMorphology>
    <feComposite in="DILATED" in2="SourceGraphic" operator="out"></feComposite>
  </filter>
</svg>

<style>
svg {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}
.dilate {
  filter: url(#dilate);
}
</style>

なお、SVG 要素に対して display: none を指定する方法も考えられますが、Firefox ではフィルタが無効化されてしまいます。

SVG フィルタを外部ファイル化する

見出し「SVG フィルタを外部ファイル化する」

SVG 自体を外部参照するように、SVG を外部ファイル化して、CSS の filter プロパティで参照することができます。

filter.svg
<svg xmlns="http://www.w3.org/2000/svg">
  <filter id="dilate">
    <feMorphology operator="dilate" radius="1" result="DILATED"></feMorphology>
  </filter>
</svg>
style.css
.dilate {
  filter: url('./filter.svg#dilate');
}

この方法であれば SVG 要素のサイズの問題も id 属性の重複も気にしなくてよく、外部ファイルで一元管理できるためメンテナンス性の観点からも望ましいのですが、残念ながら Safari が対応していないようです。

ただし、使用できる場合でもファイルリクエストが発生するため、パフォーマンス面への影響は考慮する必要があります。

現時点で把握している SVG フィルタの懸念点は以上ですが、さらに詳しく見ていくとそのほかのバグが見つかるかもしれません。

今回、-webkit-text-stroke とバリアブルフォントの問題の解決を出発点に SVG フィルタでの縁取り文字の実装方法を説明しました。後半で見てきたように、SVG フィルタにも懸念すべき点がいくつかあり、ベストな選択肢とは言い難いかもしれません。

そもそもブラウザのバグが解消されれば悩まなくてすむのですが、現時点では縁取り文字の表現にはリスクがあることを認識しておいた方がよいでしょう。

もし、今回紹介したよりも適切な方法が見つかりましたら、改めて記事にできればと考えています。

本記事の作成にあたり、以下のウェブページを参考にしました。

脚注

  1. aria-label Does Not Translate | Adrian Roselli