「HTML Popover API 実践ガイド」JSなしでツールチップ・メニュー・モーダルを作る

ツールチップ、ドロップダウン、確認ダイアログ。これまでJSで頑張ってきたUIが、HTMLの属性ふたつで動く時代に。Popover APIの実用パターンを一気に押さえます。

「クリックで開いて、外側クリックで閉じる」を毎回書いていた頭の悪さ

ドロップダウンメニュー、通知バナー、共有メニュー、確認モーダル。Web制作の現場で何度書いたか分からないUIですが、毎回ちょっとずつ書き方が違って、毎回ちょっとずつバグる。

外側クリックで閉じる処理、ESCキーで閉じる処理、開いたときのフォーカス管理、aria属性のトグル。地味に手間で、地味に間違えやすい。2年前のアパレル系ECサイトの案件で、ヘッダーメニューの開閉処理だけでQAから不具合チケットが7枚立ったときは、正直、心が折れました。

これを「HTML標準の機能」として一気に解決するのが Popover API。Chrome 114(2023年5月)、Safari 17(2023年9月)、Firefox 125(2024年4月)で対応済み。popover属性とpopovertarget属性、たったこの2つを書くだけで、「クリックで開いて、外側クリック・ESCで閉じる」が動きます。

📌 Popoverができること
任意の要素を「ポップアップ的に開閉できるUI」として扱えるようになります。クリック外で自動的に閉じる「light-dismiss」、ESCキーでの閉じる動作、最前面(top-layer)への自動配置、これら全部がブラウザ任せ。

最小構成は、HTML属性ふたつだけ

百聞は一見にしかず。まずはコードを見てください。

HTMLだけで動くポップオーバー
<!-- ボタン側:開きたい要素のidを popovertarget に書く -->
<button popovertarget="my-popover">開く</button>

<!-- ポップオーバー側:popover属性を付けるだけ -->
<div id="my-popover" popover>
  ここに表示する中身
</div>

JSは一行もありません。ボタンにpopovertarget="my-popover"を書いて、対応する要素にpopover属性を付ける。これでクリックすれば開き、外側をクリックすれば閉じます。ESCキーでも閉じます。フォーカスも自動で管理されます。

初めて動かしたとき、本当にこれだけで動くのか半信半疑でDevToolsを開いて確認したくらいです。これは効きます。

▶ Live Demo — ボタンクリックでツールチップが開く・外をクリックで閉じる
この機能を使うと、アカウント設定を素早く変更できます。詳しくは設定画面のヘルプを参照してください。
💡 popoverの値は3種類
popover(またはpopover="auto")は外側クリックで閉じる挙動。popover="manual"は外側クリックでは閉じず、JSや専用ボタンで明示的に閉じる必要があります。トーストやスナックバーは後者が向いています。

ドロップダウンメニューを作る

Popoverの実務での出番として一番多いのが、メニュー系UIです。ヘッダーのユーザーメニュー、共有ボタンの選択肢、テーブル行のアクションメニュー。今までこれを@headlessui/reactのMenuコンポーネントに頼って実装していた人、けっこう多いはずです。

ドロップダウンメニューの実装
<button popovertarget="user-menu">メニュー ▾</button>

<div id="user-menu" popover class="menu">
  <a href="/profile">プロフィール</a>
  <a href="/settings">設定</a>
  <hr>
  <a href="/logout">ログアウト</a>
</div>

位置調整は今までだとJSで親要素の座標を計算してgetBoundingClientRect()でゴニョゴニョ、というのが定番でした。これもCSS Anchor Positioningと組み合わせれば、純粋にCSSだけで解決できます。

CSS Anchor Positioningでボタンの真下に配置
/* ボタン側:アンカー名をつける */
.menu-btn {
  anchor-name: --menu-anchor;
}

/* ポップオーバー側:アンカーを基準に配置 */
.menu {
  position-anchor: --menu-anchor;
  top: anchor(bottom);
  left: anchor(left);
  margin-top: 8px;
}
▶ Live Demo — メニューボタンをクリックして開く

確認モーダルを作る:<dialog>との使い分け

「本当に削除しますか?」のような確認モーダル。これ、HTMLには<dialog>要素もあって役割が重なって見えるんですが、現場での使い分けは明確です。

📌 dialogとpopoverの使い分け
<dialog>:ユーザーの返答が必要なもの。フォーム送信、削除確認、ログインなど「タスクを完了させたい」UI。
popover:補助的に開閉する軽量UI。ツールチップ、メニュー、通知、ヘルプテキストなど「ちょっと見せたい」UI。
モーダル的に背景を暗くしたい場合は<dialog>showModal()で開くのが筋。ただし、最近はPopoverでもbackdrop疑似要素が使えるので、軽い確認ダイアログ程度なら使えます。
Popoverで確認ダイアログ風UI
<button popovertarget="confirm">削除する</button>

<div id="confirm" popover class="modal">
  <h3>本当に削除しますか?</h3>
  <p>この操作は元に戻せません。</p>
  <button popovertarget="confirm" popovertargetaction="hide">
    キャンセル
  </button>
  <button popovertarget="confirm" popovertargetaction="hide">
    削除する
  </button>
</div>

注目してほしいのがpopovertargetaction="hide"。これを付けたボタンは「閉じる専用ボタン」になります。showhidetoggleの3種類が指定可能。JSでpopover.hidePopover()を書く必要が減ります。

背景を暗くするCSS
.modal {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
/* popover専用の擬似要素で背景を暗くできる */
.modal::backdrop {
  background: rgba(0, 0, 0, 0.4);
}
▶ Live Demo — 削除ボタンで確認ダイアログを表示

本当に削除しますか?

この操作は元に戻せません。アカウントに紐付いた全データが完全に消去されます。

開閉アニメーションを付ける:@starting-styleが鍵

標準のままだとパッと表示されるので、ちょっと味気ない。フェードインや拡大アニメを付けたいところです。ところが、popover要素はdisplay: nonedisplay: blockを行き来する性質上、普通のtransitionが効きません。

❌ これだけでは動かない
.popover {
  opacity: 0;
  transition: opacity 0.2s;
}
.popover:popover-open {
  opacity: 1;
}
/* displayが none ⇔ block で切り替わるため、 */
/* 開いた瞬間にはすでに opacity:1 になっていてアニメしない */

解決策は3点セット。transition-behavior: allow-discreteでdisplayプロパティもtransition対象にし、@starting-styleで「表示開始時の初期値」を別途定義します。

✅ 正しい書き方
.popover {
  opacity: 0;
  transform: scale(0.95);
  transition: opacity 0.2s, transform 0.2s,
              display 0.2s allow-discrete,
              overlay 0.2s allow-discrete;
}
.popover:popover-open {
  opacity: 1;
  transform: scale(1);
}
/* 表示開始時の初期値を定義 */
@starting-style {
  .popover:popover-open {
    opacity: 0;
    transform: scale(0.95);
  }
}

ハマりやすいのがoverlayプロパティの指定。popoverはtop-layer(最前面の特別な描画レイヤー)に乗るので、消える瞬間にtop-layerから外れるタイミングもtransitionに含めないと、アニメ途中で消えてしまう。これ、最初知らなくて30分ハマりました。

JSから操作する、フォールバックを書く

HTML属性だけで完結するのが魅力ですが、現場ではJSから開閉したい場面もあります。検索フィールドで結果が出たら自動で開く、API応答後に通知を表示する、など。

JSでの開閉API
const popover = document.getElementById('my-popover');

// 開く */
popover.showPopover();

// 閉じる */
popover.hidePopover();

// トグル */
popover.togglePopover();

// 開閉時のイベント */
popover.addEventListener('toggle', (e) => {
  if (e.newState === 'open') {
    // 開いた直後の処理 */
  }
});

非対応ブラウザの考慮も忘れずに。2024年4月でFirefoxが対応したのでモダンブラウザはほぼカバーできていますが、古いSafari(17未満)が残っている環境では、属性が無視されて常時表示になります。

対応チェックとフォールバック
if (!HTMLElement.prototype.hasOwnProperty('popover')) {
  // 非対応:oddbird/popover-polyfill などを読み込むか、 */
  // 古い実装にフォールバックする */
  document.querySelectorAll('[popover]').forEach(el => {
    el.hidden = true;
  });
}
⚠️ アクセシビリティの注意
Popoverはaria-expandedなどの属性をブラウザが自動管理してくれますが、トリガーボタンが「何を開くのか」をスクリーンリーダーに伝える役割は別。アイコンだけのボタンにはaria-labelを必ず付けること。これは昔も今も変わらない基本です。

まとめ

  • Popover APIは、HTMLのpopover属性とpopovertarget属性だけで、開閉できるUIを作れる新機能。Chrome 114、Safari 17、Firefox 125で全モダンブラウザ対応済み。
  • 外側クリックで閉じる「light-dismiss」、ESCキーで閉じる、フォーカス管理、最前面への配置、これら全部をブラウザが自動でやってくれる。
  • popover="auto"はメニューやツールチップ向け、popover="manual"はトーストや通知バナーなど明示的に閉じたいUIに向いている。
  • popovertargetaction="hide"を付けたボタンは「閉じる専用ボタン」として動作する。JSで閉じる処理を書く必要が減る。
  • 位置調整はCSS Anchor Positioningと組み合わせると、ボタンの真下や横にCSSだけで配置できる。
  • 開閉アニメーションはtransition-behavior: allow-discrete@starting-style、そしてoverlayプロパティの3点セットで実現する。
  • ユーザーの返答が必須なモーダルは<dialog>、補助的な開閉UIはpopover、という使い分けが現場では筋がいい。
  • 古いSafariが残る案件ではoddbird/popover-polyfillなどのpolyfillか、機能検出でのフォールバックを忘れずに。