CSSで、吹き出しの形を作る方法

​​CSS​HTML,Playwright

吹き出しは、角を丸めた箱に、三角形のしっぽを付けると作れます。しっぽは、::after で幅と高さが 0 の箱を作り、上の辺の border だけに色を付けると描けます。残りの3辺を透明にすると、下向きの三角形が残ります。画像も SVG も使わず、HTML の要素も1つで済みます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS だけのページを Playwright で開き、getComputedStyle() の値と、スクリーンショットの画素の色を読んで、しっぽの形を確かめました。Firefox と Safari、スマートフォンの実機での表示は試していません。

上から3つの吹き出しを並べたもの。1つ目は水色の吹き出しで、左下に下向きの三角形のしっぽがある。2つ目は白地に濃い水色の枠線の吹き出しで、しっぽにも同じ枠線が続いている。3つ目は水色の吹き出しで、左の辺の上のほうに、左向きのしっぽがある
記事の CSS で作った3つの吹き出しを、Chromium で撮ったもの(各行の見出しの文字は画像用に足したもの)

border だけの箱は、4つの三角形でできている

しっぽの仕組みを先に見ておきます。幅と高さを 0 にした箱に、太さ 40px の border を付け、4辺の色を変えると、箱は4つの三角形に分かれて見えます。検証環境で撮った画面では、4辺は、箱の角から中心へ向かう斜めの線で分かれていました。

css
.box {
  width: 0;
  height: 0;
  border: 40px solid;
  border-color: #00c8e8 #d85878 #9aa5ab #1f2a30; /* 上 右 下 左 */
}

検証環境でこの箱の大きさを読むと 80px 四方で、上・右・下・左の三角形の中の画素は、それぞれ指定した4つの色でした。ここで上の辺だけに色を残し、ほかの3辺を transparent にすると、下向きの三角形だけが見えます。

左は、幅と高さが 0 の箱に4辺で違う色の border を付けたもの。上が水色、右がピンク、下が灰色、左が黒の4つの三角形に分かれている。右は、上の辺だけに水色を付けたもので、下向きの三角形だけが見え、残りは透明になっている
border だけの箱を、4辺の色を変えたときと、上の辺だけに色を付けたときで比べたもの(点線は、箱全体の範囲が分かるように画像用に足したもの)

透明な3辺も場所は取っているので、箱は 80px 四方のままです。見える三角形の大きさは border の太さで決まり、次の節の border 10px の吹き出しでは、幅 20px 弱、高さ 10px 弱の三角形になりました。

基本の吹き出しは、::after でしっぽを付ける

html
<p class="bubble">こんにちは。今日はいい天気ですね。</p>
css
/* 吹き出しの本体 */
.bubble {
  position: relative;
  max-width: 280px;
  padding: 12px 16px;
  border-radius: 12px;
  background: #c6f0f8;
  color: #1f2a30;
}

/* しっぽ:幅と高さが 0 の箱の、上の辺の border だけに色を付ける */
.bubble::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 24px;
  border: 10px solid transparent;
  border-top-color: #c6f0f8;
}

しっぽは、本体の ::after 擬似要素で描きます。MDN によると、content を指定しないと ::after は表示されません。文字を出さないので、content: "" と空の文字列を指定します。

MDN の包含ブロックの説明によると、position: absolute の要素は、position が static 以外のいちばん近い祖先を基準に置かれ、top のパーセント値は、その祖先のパディングボックス(枠線の内側)の高さから計算されます。ここでは position: relative を付けた本体が基準になり、top: 100% は本体の枠線の内側の高さと同じだけ下という意味になるので、しっぽは本体の下の辺のすぐ下から始まります。検証環境で読んだ ::after の値は、top が 48px(本体の高さと同じ)、幅と高さは 0px、上の border だけが本体と同じ rgb(198, 240, 248) で、残りの3辺は透明でした。

しっぽの形を、スクリーンショットの画素で確かめました。本体の下の辺から1行ずつ下へ、本体と同じ色の画素が横に何 px 並んでいるかを数えた結果です。

本体の下の辺から 本体と同じ色の幅
0px(すぐ下の行) 18px
2px 14px
4px 10px
6px 6px
8px 2px
9px 0px

1行下がるごとに 2px ずつ細くなり、高さ 10px ほどで先が閉じています。この表は本体とまったく同じ色の画素だけを数えたもので、斜めの辺にかかる画素は含んでいません。しっぽの中心は、本体の左端から 34px(left の 24px と、border の太さの 10px を足した位置)にあります。しっぽの位置を変えたいときは left を、大きさを変えたいときは border の太さを変えます。

枠線のある吹き出しは、三角形を2つ重ねる

本体の border は本体の箱にだけ付きます。しっぽの三角形は ::after の箱の border でできているので、本体の枠線は付きません。しっぽにも枠線を出すには、枠線の色の大きい三角形を ::before で描き、その上に背景の色の小さい三角形を ::after で重ねます。

css
/* 枠線のある吹き出し */
.bubble--outline {
  border: 2px solid #067a98;
  background: #fff;
}

/* 枠線の色の三角形(外側) */
.bubble--outline::before {
  content: "";
  position: absolute;
  top: 100%;
  left: 22px;
  border: 12px solid transparent;
  border-top-color: #067a98;
}

/* 背景の色の三角形(内側)。外側より少し小さくして重ねる */
.bubble--outline::after {
  left: 24px;
  border-width: 10px;
  border-top-color: #fff;
}

外側の三角形は border の太さ 12px で幅 24px、内側は 10px で幅 20px です。left を 2px ずらしているので、2つの中心はそろいます。検証環境では、::after で描いた内側の白い三角形が上に重なり、外側の三角形は縁だけが見えました。

ここで気をつけたいのは、本体に枠線があると、top: 100% や left が、枠線の内側から測られることです。MDN の包含ブロックの説明にあるとおり、position: absolute の要素の位置は、基準になる祖先の要素のパディングボックス(枠線の内側)の辺から決まります。検証環境では、枠線を含めた本体の高さは 52px でしたが、::before と ::after の top は、どちらも 48px でした。

そのため、2つの三角形は、下の枠線の 2px に重なった位置から始まります。しっぽの中心の列の画素を、上から順に読んだ結果です。

本体の下の端からの位置 色
2px 上〜1px 上(下の枠線の中) 白
すぐ下〜6px 下 白
7px 下と 9px 下 白と混ざった濃い水色(しっぽの先の線の縁)
8px 下 濃い水色(しっぽの先の線)
10px 下より下 白

しっぽから離れた列では、下の枠線の 2px は濃い水色でした。しっぽの付け根だけは、内側の白い三角形が枠線を隠しているので、本体としっぽの中がつながって見えます。しっぽの左の斜めの辺も、本体の下の端から 4px 下の行を横に読むと、濃い水色の画素が1つと、白と混ざった画素が左右に1つずつあり、線になっていました。

しっぽの向きは、色を付ける辺で変わる

しっぽを左に出すときは、右の辺の border に色を付けると左向きの三角形になり、本体の左の外側に置きます。

css
/* しっぽを左に出す */
.bubble--left::after {
  top: 12px;
  left: auto;
  right: 100%;
  border-top-color: transparent;
  border-right-color: #c6f0f8;
}

基本の吹き出しから受け継ぐ left: 24px と上の辺の色は、auto と transparent で打ち消します。そのうえで right: 100% を指定し、しっぽの箱の右の辺を本体の左の辺に合わせます。検証環境で、本体の左の辺から1列ずつ左へ、本体と同じ色の画素が縦に何 px 並ぶかを数えると、すぐ左の列で 18px、そこから 1px 左へ行くごとに 2px ずつ短くなり、10px 左で 0px になりました。下向きのしっぽを横に倒した形です。

上向きと右向きも、基本の吹き出しの border-top-color を transparent にしたうえで、色を付ける辺と置き方を入れ替えて試しました。どの向きも、本体のすぐ外の列(行)で 18px、そこから 2px 離れるごとに 4px ずつ短くなる三角形になりました。組み合わせは次のとおりです。

しっぽの向き 色を付ける辺 置き方
下向き(本体の下) border-top-color top: 100%
左向き(本体の左) border-right-color top: 12px; left: auto; right: 100%
上向き(本体の上) border-bottom-color top: auto; bottom: 100%
右向き(本体の右) border-left-color top: 12px; left: 100%

しっぽは三角形の向きと反対側の辺に色を付ける、と覚えておくと迷いません。

overflow: hidden を付けると、しっぽが消える

本体に overflow: hidden を付けると、しっぽは表示されなくなりました。しっぽの中心にあたる、本体の下の端から 1px 下と 5px 下の画素は、どちらもページの背景と同じ白でした。::after の値は overflow のないときと同じ(top: 48px など)なので、しっぽはその場所にあるのに、見えていません。

MDN の overflow の説明によると、hidden のときは、はみ出したコンテンツが要素のパディングボックスで切り取られます。しっぽは本体の外にはみ出して描くものなので、はみ出した部分が切り取られます。吹き出しの本体には overflow: hidden を付けないようにします。

影を付けるなら、box-shadow ではなく drop-shadow

上から3つの吹き出しを並べたもの。1つ目は overflow: hidden を付けたもので、しっぽがない。2つ目は box-shadow で影を付けたもので、本体の四角い形の下にだけ灰色の影があり、しっぽの下には影がない。3つ目は filter: drop-shadow で影を付けたもので、本体の下としっぽの先の下の両方に、しっぽの形に沿った影がある
overflow: hidden、box-shadow、filter: drop-shadow を、それぞれ基本の吹き出しに付けて Chromium で撮ったもの(各行の見出しの文字は画像用に足したもの)

本体に box-shadow: 0 4px 4px rgb(0 0 0 / 0.35) を付けると、影は本体の四角い形にだけ付き、しっぽには付きませんでした。代わりに filter: drop-shadow(0 4px 4px rgb(0 0 0 / 0.35)) を付けると、しっぽの形に沿った影が出ました。しっぽの中心の列で、本体の下の端からの位置ごとに画素の色を読んだ結果です。

本体の下の端から box-shadow drop-shadow
10px(しっぽの先のあたり) rgb(255, 255, 255) rgb(209, 209, 209)
14px rgb(255, 255, 255) rgb(236, 236, 236)
18px rgb(255, 255, 255) rgb(251, 251, 251)

box-shadow では、しっぽの先より下は白のままでした。drop-shadow では、しっぽの先の下が灰色になり、下へ行くほど薄くなっています。しっぽから離れた列では、どちらも 14px 下で白(rgb(255, 255, 255))だったので、この灰色はしっぽの影です。

MDN の drop-shadow() の説明にも、box-shadow は要素のボックス全体の後ろに長方形の影を作り、drop-shadow() は画像の形(アルファチャネル)に合った影を作る、とあります。検証の結果も、この説明のとおりでした。

しっぽは、アクセシビリティツリーに出ない

しっぽは content: "" の擬似要素で、文字を持ちません。Playwright で3つの吹き出しのアクセシビリティツリーを読むと、どれも本文の段落だけが出ていました。

text
- paragraph: こんにちは。今日はいい天気ですね。
- paragraph: 枠線のある吹き出しです。
- paragraph: しっぽを左に出しました。

しっぽを描くために HTML の要素を足していないので、ツリーに出るのは本文の段落だけです。

​Posted by 86