CSSで、マウスを乗せると吹き出しの説明を出す方法
マウスを乗せると出る吹き出しの説明(ツールチップ)は、ボタンと吹き出しを1つの要素で包み、包んだ要素の :hover で吹き出しの visibility と opacity を切り替えると作れます。transition-delay で0.3秒待ってから出すと、マウスが通り過ぎただけでは出ません。出し入れは CSS だけで動き、Esc キーで閉じる部分にだけ、数行の JavaScript を足します。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS のページを Playwright で開き、マウスとキーボードで実際に操作して、getComputedStyle() の値と、Chromium のアクセシビリティツリー(CDP の Accessibility.getFullAXTree)を読みました。Firefox と Safari、スマートフォンの実機でのタッチ操作、スクリーンリーダーでの実際の読み上げは試していません。

コード全体
HTML は、ボタンと吹き出しを span.tooltip で包みます。吹き出しには role="tooltip" と id を付け、ボタンの aria-describedby でその id を指します。
<span class="tooltip">
<button type="button" aria-describedby="tip-save">保存</button>
<span class="tooltip-text" role="tooltip" id="tip-save">いまの内容を下書きとして保存します</span>
</span>
<script src="tooltip.js"></script>
CSS は次のとおりです。
.tooltip {
position: relative;
display: inline-flex; /* ボタンと同じ大きさにする */
}
/* 吹き出し。ふだんは見えず、マウスも当たらない */
.tooltip-text {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 10px;
width: max-content;
max-width: 240px;
padding: 6px 10px;
border-radius: 6px;
background: #1f2a30;
color: #fff;
font-size: 14px;
line-height: 1.5;
visibility: hidden;
opacity: 0;
transition: opacity 0.15s, visibility 0.15s;
}
/* 下向きのしっぽ */
.tooltip-text::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -6px;
border: 6px solid transparent;
border-top-color: #1f2a30;
}
/* ボタンと吹き出しのすき間を埋める、透明な橋 */
.tooltip-text::before {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 10px;
}
/* マウスを乗せたとき、キーボードで移ってきたときに出す */
.tooltip:hover .tooltip-text,
.tooltip:has(:focus-visible) .tooltip-text {
visibility: visible;
opacity: 1;
transition-delay: 0.3s;
}
/* Esc キーで閉じたとき */
.tooltip.is-dismissed .tooltip-text {
visibility: hidden;
opacity: 0;
transition: none;
}
tooltip.js は、Esc キーで閉じるためだけのものです。
// Esc キーで、いま出ている吹き出しを閉じる
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
document.querySelectorAll(".tooltip").forEach((tooltip) => {
if (tooltip.matches(":hover, :focus-within")) tooltip.classList.add("is-dismissed");
});
});
// マウスが離れたか、フォーカスが外れたら、また出せるようにする
document.querySelectorAll(".tooltip").forEach((tooltip) => {
tooltip.addEventListener("mouseleave", () => tooltip.classList.remove("is-dismissed"));
tooltip.addEventListener("focusout", () => tooltip.classList.remove("is-dismissed"));
});
吹き出しは bottom: 100% でボタンの上端に下端を合わせ、margin-bottom: 10px で10px離しています。left: 50% と translateX(-50%) で、吹き出しの真ん中をボタンの真ん中にそろえます。検証したページでは、吹き出しとボタンの真ん中の横位置の差は0px、すき間は10pxでした。しっぽは、中身が空の ::after に太さ6pxの透明な border を付け、上の辺だけに色を付けた下向きの三角形です。
包む要素を inline-block にしたときは、吹き出しとボタンのすき間が10pxではなく13pxになりました。高さ24pxのボタンに対して、包む要素の高さが27pxになり、吹き出しの基準になる上端が3px上にずれたためです。inline-flex にすると、包む要素の高さはボタンと同じ24pxになりました。
visibility で隠し、0.3秒待ってから出す
吹き出しは、ふだん visibility: hidden と opacity: 0 で隠しています。visibility の説明にあるとおり、visibility はレイアウトを変えずに要素を見えなくします。visibility: hidden の要素には、マウスが当たりません。ボタンに乗せる前に、吹き出しが出るはずの場所の真ん中にある要素を document.elementFromPoint() で調べると、吹き出しではなく、その後ろの要素でした。そこにマウスを0.6秒置いても、吹き出しは出ませんでした。
マウスを乗せたときのルールには transition-delay: 0.3s があります。transition-delay は、値が変わってから transition が始まるまでの待ち時間です。乗せた直後のアニメーションを止め、時刻を指定して吹き出しの値を読むと、次のようになりました。
| 乗せてからの時間 | visibility |
opacity |
|---|---|---|
| 0秒 | hidden | 0 |
| 0.15秒 | hidden | 0 |
| 0.3秒 | hidden | 0 |
| 0.375秒 | visible | 0.802 |
| 0.45秒 | visible | 1 |
0.3秒たつまでは何も変わらず、そこから0.15秒かけて現れます。外したときは、ふだんのルールの transition(待ち時間なし、0.15秒)が使われます。外して0.075秒で opacity は0.198まで下がり、0.149秒では opacity が0でも visibility は visible のままで、0.15秒で hidden に戻りました。
visibility は、visible と hidden の途中の値を持ちません。visibility の説明の「補間」の項にあるとおり、transition の途中はずっと visible として扱われます。そのため、出るときは動き始めた瞬間に visible になり、消えるときは transition が終わるまで visible のままです。
待ち時間があると、マウスがボタンの上を通り過ぎただけでは吹き出しは出ません。実際の時間でマウスを乗せて外し、画面が描き変わるたびに opacity を読むと、乗せていた時間が0.1秒と0.25秒のときは、opacity は一度も0より大きくならず、visible にもなりませんでした。0.4秒乗せたときは、いちばん大きいときで0.969まで現れました。
ボタンと吹き出しのすき間に、透明な橋をかける
吹き出しの文章が長いときや、画面を拡大して見ているときは、マウスを吹き出しの上へ動かして読みたいことがあります。WCAG 2.1 の達成基準 1.4.13 の解説でも、ホバーで出た内容は、その内容の上にマウスを移しても消えないこと(hoverable)を求めています。
吹き出しは .tooltip の子なので、吹き出しの上にマウスがあれば、.tooltip:hover は当てはまったままです。ところが、ボタンと吹き出しの間には10pxのすき間があります。ここを通るとき、マウスは .tooltip の外に出ます。
::before の橋を消した CSS で、ボタンの左端から4px、上端から5px上の点(すき間の中)に、0.4秒マウスを置きました。吹き出しは hidden に戻り、そのあと吹き出しがあった場所へマウスを動かしても、もう出ませんでした。隠れた吹き出しにはマウスが当たらないので、一度消えると、ボタンに乗せ直すまで出ません。
吹き出しの ::before は、すき間と同じ高さ10pxの透明な箱を、吹き出しの下に置いています。橋がある CSS で同じ点を調べると、そこにある要素は吹き出しで、0.4秒置いても、そのあと吹き出しの上へ動かしても、吹き出しは出たままでした。
橋がない CSS でも、すき間の真ん中だけは吹き出しの一部でした。しっぽ(::after)が、すき間の真ん中にかかっているためです。ただし、しっぽの幅は12pxしかなく、そこから外れたすき間で止まると、吹き出しは消えます。
キーボードで移ってきたときは :has(:focus-visible) で出す
マウスを使わない人のために、Tab キーでボタンに移ってきたときにも吹き出しを出します。この記事のコードでは .tooltip:has(:focus-visible) にしています。:has() は、中に当てはまる要素がある要素を選ぶ擬似クラスです。Tab キーでボタンに移ると、0.6秒後に吹き出しは visible になっていました。
似た擬似クラスの :focus-within を使うと、マウスでクリックしたときに困ります。Chromium では、ボタンをクリックするとボタンにフォーカスが移ります。:focus-within は、包んだ要素の中にフォーカスがあれば当てはまるので、クリックしたあとマウスを遠くへ動かしても、0.6秒後に吹き出しは出たままでした。
:focus-visible は、フォーカスがあり、かつブラウザがフォーカスを見せるべきだと判断したときにだけ当てはまります。同じようにクリックしてマウスを遠くへ動かすと、ボタンにはフォーカスがありましたが :focus-visible は当てはまらず、吹き出しは hidden に戻りました。
Esc キーで閉じる部分だけ、JavaScript を足す
WCAG 2.1 の 1.4.13 は、ホバーやフォーカスで出た内容を、マウスやフォーカスを動かさずに閉じられること(dismissible)も求めています。解説には、その方法の例として Esc キーが挙がっています。WAI-ARIA の tooltip ロールの説明にも、Esc キーで閉じるべきだと書かれています。
CSS には、キーを押したことに当てはまる擬似クラスがありません。JavaScript のないページで、Tab キーでボタンに移ってから Esc キーを押しても、吹き出しは出たままでした。
tooltip.js は、Esc キーが押されたとき、マウスが乗っているかフォーカスがある .tooltip に is-dismissed クラスを付けます。CSS の .tooltip.is-dismissed .tooltip-text が、transition: none で吹き出しをすぐに隠します。
キーの処理は document に付けています。マウスを乗せただけのときは、フォーカスはボタンになく、body にありました。この状態でも、Esc キーを押して0.05秒後には吹き出しが hidden になりました。Tab キーで移ってきたときも、同じく0.05秒後に hidden になりました。
is-dismissed は、マウスが .tooltip から離れたとき(mouseleave)と、フォーカスが外れたとき(focusout)に外します。Esc キーで閉じたあと、マウスを一度外して乗せ直すと、吹き出しはまた出ました。キーボードでも、Tab キーでボタンからフォーカスを外し、Shift+Tab キーで戻ると、また出ました。
説明文は aria-describedby で結びつける
吹き出しの文章は、画面の見た目だけでなく、スクリーンリーダーにも「ボタンの説明」として伝えたいものです。aria-describedby は、要素を説明する別の要素の id を指す属性で、指した要素は隠れていてもかまいません。
Chromium のアクセシビリティツリーで、ボタンの名前(name)と説明(description)を読みました。
| 書き方 | 吹き出しの状態 | ボタンの名前 | ボタンの説明 |
|---|---|---|---|
この記事の書き方(aria-describedby) |
隠れている | 保存 | いまの内容を下書きとして保存します |
この記事の書き方(aria-describedby) |
出ている | 保存 | いまの内容を下書きとして保存します |
ボタンの data-tooltip 属性を ::after { content: attr(data-tooltip) } で出す |
隠れている | 保存 | なし |
ボタンの data-tooltip 属性を ::after { content: attr(data-tooltip) } で出す |
出ている | 保存 いまの内容を下書きとして保存します | なし |
aria-describedby で結びつけると、吹き出しが隠れていても出ていても、名前は「保存」、説明は吹き出しの文章になりました。
HTML の要素を増やさずに、ボタンの属性に書いた文章を ::after で出す書き方もあります。この書き方では、説明は空でした。吹き出しが出ている間は、::after の文章がボタンの名前に加わり、名前が「保存 いまの内容を下書きとして保存します」に変わりました。
対応しているブラウザ
MDN の互換性データ(@mdn/browser-compat-data 8.1.8)で、この記事のコードで使っている主な CSS と DOM の機能を調べました。調べた機能のうち、使えるようになった版がいちばん新しいのは :has() で、Chrome 105、Firefox 121、Safari 15.4 からです。:focus-visible は Chrome 86、Firefox 85、Safari 15.4 から、width: max-content は Chrome 46、Firefox 66、Safari 11 から使えます。
:has() が使えないブラウザでは、マウスで乗せても吹き出しが出なくなります。セレクターリストの説明にあるとおり、カンマで並べたセレクターの中に1つでも分からないものがあると、ルール全体が無視されるためです。検証に使った Chromium 153 は :has() が使えるので、代わりに :has(:focus-visible) を存在しない擬似クラスに書きかえて試しました。マウスを乗せても、Tab キーで移ってきても、0.6秒後の吹き出しは hidden のままでした。
古いブラウザでもマウスで出したいときは、.tooltip:hover .tooltip-text と .tooltip:has(:focus-visible) .tooltip-text を、別々のルールに分けて書きます。コード全体の CSS のうち、「マウスを乗せたとき、キーボードで移ってきたときに出す」ルールを、次の2つに置きかえます。
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
transition-delay: 0.3s;
}
.tooltip:has(:focus-visible) .tooltip-text {
visibility: visible;
opacity: 1;
transition-delay: 0.3s;
}
この CSS でも、2つ目のルールの :has(:focus-visible) を存在しない擬似クラスに書きかえて試しました。マウスを乗せると、0.6秒後に吹き出しは visible になりました。2つ目のルールが無視されても、1つ目のルールは使われるためです。一方、Tab キーでボタンに移ってきたときは、ボタンには :focus-visible が当てはまっていましたが、0.6秒後の吹き出しは hidden のままでした。
擬似クラスを書きかえず、ルールを分けただけの CSS も Chromium 153 で試しました。マウスを乗せたときも、Tab キーで移ってきたときも、0.6秒後に吹き出しは visible になり、1つのルールにまとめたときと同じように出ました。





