Popover APIとは?JSなしでツールチップを開閉する方法
popover属性1つで開閉・Escキー・アクセシビリティが自動動作するブラウザネイティブUI
この記事を読むと、ツールチップ・メニュー・トーストをJavaScriptなしで開閉し、ボタンのすぐ下に置けるようになる。モーダルはpopoverではなくdialog、という線引きも。
<button popovertarget="tip-1">ヘルプ</button>
<div id="tip-1" popover="auto" role="tooltip">
ネイティブツールチップ
</div>
JavaScript 0行。イベントリスナーなし。状態追跡なし。それでもEscで閉じ、キーボードでアクセスでき、スクリーンリーダーが正しく読み上げる。
先にひとつだけ。これはボタンを押したときに開く(キーボードのEnter・Spaceも含む)。マウスを乗せただけで出るツールチップは、まだJavaScriptの仕事だ。下の「まだJavaScriptが必要な領域」でまた出てくる。
従来のツールチップが壊れる理由
「ホバーで表示、離れたら非表示」という単純な要件。実際にデプロイすると問題が噴出する。
キーボードユーザーがTabでトリガーに到達してもツールチップが出ない。スクリーンリーダーが2回読むか、全く読まない。マウスを速く動かすとちらつく。小さい画面で重なる。Escで閉じない。フォーカスが失われる。
イベントリスナーが積み重なる。hover/focus別々の処理。外部クリックの特殊ケース。ARIA属性の手動同期。コードが肥大化する。実装の問題ではなく、Webプラットフォームに「この要素はツールチップだ」という概念がなかったからだ。
Popover APIが変えたこと
popover属性を付けるとブラウザがこの要素の役割を理解する。3つが自動で解決。
1. JavaScriptなしで開閉
popovertarget="id"でボタンとpopoverを接続するだけ。クリック(Enter・Spaceを含む)での開閉をブラウザが処理する。popovertargetactionでshow、hide、toggle(デフォルト)を指定可能。
2. Escキー自動処理
グローバルkeydownハンドラ不要。Esc専用のクリーンアップロジック不要。ブラウザがpopoverを解除可能な要素と認識する。
3. ARIA状態の自動同期
トリガーのボタンが「いま開いている/閉じている」をスクリーンリーダーに伝えるのはブラウザの担当になる。DOMにaria-expanded属性が付くわけではなく、アクセシビリティツリーの側で処理される。だからJSで属性を合わせる必要がなく、開閉状態とずれることもない。
z-index合戦が終わる — top layer
開いたpopoverはtop layerに描かれる。ページの他のすべての上にある別の層なので、z-index: 9999の要素よりも上に出る。親にoverflow: hiddenがかかっていても切れない。
カードの中のドロップダウンが切れて、結局DOMをbodyの末尾に移したことがある人には、ここがいちばん嬉しいところだと思う。
背景を少し暗くしたいなら::backdropを使う。
[popover]::backdrop {
background: rgb(0 0 0 / 0.3);
}
フォーカスは閉じ込めない — モーダルならdialog
popoverが開いていても、Tabで外の要素に移れる。後ろのページを触れなくする(フォーカストラップ)こともしない。
確認ダイアログのように「これを終えるまで他を触らせない」なら、<dialog>をshowModal()で開く。こちらは残りのページをinert(操作不可)にしてくれる。
選び方は単純。ツールチップ・メニュー・トーストはpopover、答えないと先に進めない窓はmodal dialog。
auto vs manual
<!-- auto: 外側クリックで閉じる -->
<div popover="auto">auto</div>
<!-- manual: 明示的に閉じるまで残る -->
<div popover="manual">manual</div>
autoは外部クリック、Esc、別のauto popover表示で自動的に閉じる。ツールチップ向け。
manualは外側クリックでもEscでも閉じない。ボタン(popovertarget)かhidePopover()で閉じる。そのかわり複数を同時に出せるので、トースト通知に向いている。
ボタンが何個あっても同じpopoverを開ける
popovertargetはidを指しているだけなので、同じidを指すボタンをいくつ置いてもいい。ヘッダーの「?」と本文のボタンが同じヘルプを開く、という使い方ができる。popovertargetが効くのは<button>(と<input type="button">)だけで、<a>に付けても何も起きない。
開くだけのボタン、閉じるだけのボタンはpopovertargetactionで分ける。showは開くだけ、hideは閉じるだけ、デフォルトのtoggleは開いていれば閉じる。
<button popovertarget="help">?</button>
<button popovertarget="help" popovertargetaction="show">ヘルプを見る</button>
<div id="help" popover>
...
<button popovertarget="help" popovertargetaction="hide">閉じる</button>
</div>
位置はCSSで — 開いたボタンがそのままアンカー
最初にpopoverを出してみると、画面のど真ん中に出る。デフォルトのスタイルがposition: fixed + margin: autoだからだ。ツールチップならボタンの横にいてほしい。
popovertargetでつなぐと、そのボタンがpopoverのアンカー(「この要素を基準に置く」の基準点)になる。だからanchor-nameを付けなくても、position-areaの1行でボタンのすぐ下に置ける。
[popover] {
margin: 0;
position-area: bottom;
}
margin: 0は忘れやすい。デフォルトのmargin: autoが残っていると位置がずれる。anchor()やanchor-centerで細かく置くときは、position-anchor: autoも書かないとこの暗黙のアンカーは使われない(MDN)。
CSS anchor positioningを知らないブラウザではこの行が無視され、また画面の真ん中に出る。それで困るUIなら、下の「ライブラリ」の出番だ。
まだJavaScriptが必要な領域
タイミング制御。ネイティブpopoverは即座に開閉する。マウスを速く動かすとちらつく問題は残る。ディレイが必要ならJavaScript。
ホバーインテント。ブラウザはポインターがなぜ要素上にあるか分からない — 意図的か通過中か判断できない。
ただし核心が変わった。JavaScriptはもう相互作用モデル全体を所有しない。Popover APIが基本開閉を処理し、JavaScriptはその上にインテント判別だけを載せる。
ライブラリが依然有効な場合
複雑なポジショニング — ネストしたスクロールコンテナ間の衝突検出、カスタムフリッピングロジックが必要ならFloating UIが依然優位。「ボタンの下」程度なら上のposition-areaで足りるが、anchor positioningを知らないブラウザでも同じ位置に出したいなら、まだライブラリのほうが楽。
大規模デザインシステム — チーム横断で一貫した動作とガードレールが必要な場合。
アクセシビリティに慣れていないチーム — Popover APIのデフォルトは良いが、role・ラベル・フォーカス管理をいつ足すかは自分で判断する必要がある。
ほとんどの場合 — 単純なツールチップ、ドロップダウンメニュー、トースト通知 — Popover APIだけで十分だ。ライブラリなしで。
既存画面の自作ツールチップやモーダルをpopover・dialogに置き換えていて詰まったら、negabaroに気軽に聞いてほしい。
キーポイント
popover属性とpopovertargetだけで開閉・Esc・キーボードナビゲーションが自動動作
開閉状態はブラウザがアクセシビリティツリーに伝える — ARIA属性をJSで合わせる必要がない
開いたpopoverはtop layerに出る — z-indexやoverflow: hiddenで切れない。モーダル(フォーカストラップ)が要るならdialog.showModal()
autoは外側クリック/Escで閉じて1つだけ表示、manualはボタンかhidePopover()でだけ閉じて複数同時に出せる
popovertargetのボタンがそのままアンカー — position-area: bottomの1行でボタンの下に置ける(margin: 0を忘れずに)
タイミング制御やホバーインテントは依然JavaScriptが必要だが、核心モデルはブラウザ担当