HTML Popover API 完全ガイド|popover・popovertarget・light dismiss・アクセシビリティ
- HTML Popover API は、
popover属性とpopovertargetだけで Top Layer 上のオーバーレイ を宣言的に実現する標準機能だ。 autoモードの light dismiss(外側クリックで閉じる)により、従来のposition: fixed+ 自前クリックリスナー地獄から解放される。- 2026 年 6 月時点で Chrome・Safari・Firefox が揃い Baseline Widely Available 相当。
- ただしユーザー生成 HTML をそのまま popover 内に流し込むと XSS リスクは変わらないし、大量の popover を同時に開くと クリティカルレンダリングパス にレイアウトコストが載る——用途の切り分けとエスケープは従来どおり必須だ。
HTML Popover API とは
HTML Popover API(Popover 属性と関連 API)は、ツールチップ・コンテキストメニュー・設定パネル・通知トーストなど、ページ本文の上に一時的に表示する UI をブラウザネイティブで実装するための仕組みです。WHATWG HTML Living Standard に定義され、Open UI の <popup> 提案を経て popover 属性として標準化されました。
従来の実装では次のような問題が繰り返し発生していました。
position: absolute/fixedと高いz-indexの競合(ヘッダー、モーダル、サードパーティウィジェット)- 外側クリック検知のための document 全体 click リスナー(パフォーマンスと XSS 防御 監査のノイズ)
overflow: hidden親によるクリッピング- スクリーンリーダー向け
aria-expandedの手動同期
Popover API は要素を Top Layer(最前面レンダリング層)に昇格させ、上記の多くを UA(ユーザーエージェント)に委譲します。CSS 疑似クラス :popover-open と ::backdrop で見た目を制御し、JavaScript からは showPopover() / hidePopover() / togglePopover() で命令的に操作できます。
2026 年時点のブラウザ対応
| 機能 | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
popover + popovertarget | 114+ | 17+ | 125+ | 114+ |
:popover-open / ::backdrop | 114+ | 17+ | 125+ | 114+ |
popover="hint" | 133+ | 18.4+ | 未対応 | 133+ |
beforetoggle / toggle イベント | 114+ | 17+ | 125+ | 114+ |
Invoker aria-expanded 自動反映 | 114+ | 17.2+ | 125+ | 114+ |
2024 年後半に Baseline Newly Available、2026 年現在はデスクトップ・モバイル主要ブラウザで Baseline Widely Available と見なせる段階です。社内 IE 互換や古い WebView 向けのみ polyfill / フォールバックを検討すれば、新規 UI コンポーネントの第一選択肢に Popover API を置いて問題ありません。
Top Layer は dialog、popover: open な要素、Fullscreen API などが共有する描画スタックです。通常 DOM の z-index 階層とは独立しており、ページ内で最も上にペイントされます。複数の Top Layer 要素は後から開いたものが前面になり、開発者が z-index を競わせる必要がありません。詳細な描画タイミングは クリティカルレンダリングパス最適化 の「Composite」段階と関連します。
popover 属性の3モード
popover 属性は次の値を取ります(空文字 popover は auto と同義)。
| 値 | 挙動・用途 |
|---|---|
| auto(デフォルト) | light dismiss 有効。外側クリック・Esc で閉じる。1 ページに auto popover は通常 1 つ(新しい auto を開くと前の auto は閉じる)。ツールチップ・単純メニュー向け |
| manual | light dismiss 無効。明示ボタンまたは JS のみで閉じる。色選択・リッチエディタ・ドラッグ可能パネル向け |
| hint | Chrome 133+ / Safari 18.4+。他 popover 表示時に自動クローズ。hover ヒント・補助説明向け。未対応 UA では auto 相当にフォールバック設計 |
auto と light dismiss
Light dismiss とは、ユーザーが popover 外側をクリック(またはタップ)したとき、ブラウザが自動的に popover を閉じる挙動です。popover="auto" のみが対象で、manual では外側クリックは無視されます。
Light dismiss は UX 上「思わず消えた」不満とトレードオフです。フォーム入力中のパネルや、外側クリックでデータが失われる UI では manual を選び、閉じるボタンを必ず用意してください。Esc キーは auto でも manual でも閉じる操作として仕様に含まれます(アクセシビリティ baseline)。
popovertarget と popovertargetaction
宣言的な開閉は、ボタン(または <input type="button"> 等)に popovertarget 属性を付け、値に popover 要素の id を指定します。
<button popovertarget="settings-panel">設定</button>
<div id="settings-panel" popover>
<p>通知をオンにする</p>
<button popovertarget="settings-panel" popovertargetaction="hide">閉じる</button>
</div>
popovertargetaction は省略時 toggle です。明示的に制御する場合は次の値を使います。
| 値 | 動作 |
|---|---|
toggle | 開いていれば閉じ、閉じていれば開く(デフォルト) |
show | 閉じているときだけ開く(既に開いていれば noop) |
hide | 開いているときだけ閉じる |
複数ボタンから同一 popover を show / hide で分離制御するパターンは、ヘッダーの「開く」とパネル内の「閉じる」を独立させるときに有効です。
popovertarget は 同一 document 内の id 参照です。サーバー生成 HTML で id をユーザー入力から組み立てると、意図しない要素が popover として開く恐れがあります。id は開発者が固定するか、信頼できない文字列を エスケープ・サニタイズ したうえで生成してください。popover 中身に未エスケープの HTML を innerHTML で流し込む典型パターンも Stored XSS の温床です。
JavaScript API:showPopover / hidePopover / togglePopover
命令的制御は HTMLElement のメソッドで行います。
const panel = document.getElementById('settings-panel');
// 開く(既に開いていれば InvalidStateError)
panel.showPopover();
// 閉じる
panel.hidePopover();
// トグル
panel.togglePopover();
// 第2引数 force で show/hide を強制(対応ブラウザ)
panel.togglePopover({ force: true }); // 必ず show
panel.togglePopover({ force: false }); // 必ず hide
showPopover({ source })(Chrome 129+)では、開いたときの アンカー要素 を UA に伝えられ、将来の Anchor Positioning 連携やフォーカス戻しの改善に使われます。
beforetoggle と toggle イベント
状態遷移は beforetoggle(キャンセル可能)と toggle(完了後)で観測できます。
const menu = document.getElementById('user-menu');
menu.addEventListener('beforetoggle', (event) => {
// event.newState: 'open' | 'closed'
// event.oldState: 'open' | 'closed'
if (event.newState === 'open' && !userIsLoggedIn()) {
event.preventDefault(); // 開くのを阻止(対応 UA)
}
});
menu.addEventListener('toggle', (event) => {
if (event.newState === 'open') {
analytics.track('menu_opened');
}
});
Analytics や遅延ロードは toggle(確定後)にフックし、権限チェックなど 開く前に止めたい 処理は beforetoggle に置くのが定石です。
CSS::popover-open と ::backdrop
Popover 表示中のスタイルは :popover-open 疑似クラスで指定します。UA スタイルシートでは display: none がデフォルトで、open 時のみ表示されます。
[popover] {
margin: 0;
padding: 1rem 1.25rem;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
box-shadow: 0 10px 40px rgb(15 23 42 / 0.12);
background: #fff;
color: #0f172a;
max-width: min(90vw, 24rem);
}
[popover]:popover-open {
animation: popover-in 180ms ease-out;
}
[popover]::backdrop {
background: rgb(15 23 42 / 0.35);
animation: backdrop-in 180ms ease-out;
}
@keyframes popover-in {
from {
opacity: 0;
transform: scale(0.96) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
[popover]:popover-open,
[popover]::backdrop {
animation: none;
}
}
::backdrop は Top Layer 要素の背後、ページコンテンツの上に描画される半透明レイヤーです。dialog::backdrop と同様の概念で、light dismiss の「外側」視覚的フィードバックにも使えます。アニメーションを長くしすぎると INP(Interaction to Next Paint) が悪化するため、200ms 前後に抑えるのが 2026 年の実務目安です。
実装例1:最小のツールチップ風 popover
HTML と CSS のみで完結する最小例です。JavaScript は不要です。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Popover 最小例</title>
<style>
body {
font-family: system-ui, sans-serif;
padding: 3rem;
}
.help-trigger {
font: inherit;
color: #2563eb;
background: none;
border: none;
text-decoration: underline;
cursor: pointer;
}
#help-tip {
margin: 0;
padding: 0.75rem 1rem;
max-width: 20rem;
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
background: #f8fafc;
font-size: 0.875rem;
line-height: 1.5;
}
#help-tip:popover-open {
margin-top: 0.5rem;
}
</style>
</head>
<body>
<p>
API キーは
<button class="help-trigger" popovertarget="help-tip">こちら</button>
から発行できます。
</p>
<div id="help-tip" popover="auto">
ダッシュボード → 設定 → 開発者向け → API キーを生成。
キーは一度だけ表示されます。安全な場所に保管してください。
</div>
</body>
</html>
popover="auto" により、説明を読んだあと外側をクリックすれば light dismiss で閉じます。Invoker ボタンには UA が aria-expanded を自動反映します。
実装例2:ドロップダウンメニュー(HTML + JS)
キーボード操作とフォーカス移動を補完する、実務的なメニュー例です。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Popover メニュー</title>
<style>
.menu-bar {
display: flex;
gap: 0.5rem;
padding: 1rem;
background: #1e293b;
}
.menu-bar button {
font: inherit;
color: #f1f5f9;
background: transparent;
border: none;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
}
.menu-bar button:hover {
background: #334155;
}
#file-menu {
margin: 0;
padding: 0.5rem 0;
min-width: 12rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
}
#file-menu [role="menuitem"] {
display: block;
width: 100%;
padding: 0.5rem 1rem;
border: none;
background: none;
text-align: left;
font: inherit;
cursor: pointer;
}
#file-menu [role="menuitem"]:hover,
#file-menu [role="menuitem"]:focus {
background: #f1f5f9;
outline: none;
}
#file-menu::backdrop {
background: transparent;
}
</style>
</head>
<body>
<nav class="menu-bar">
<button type="button" id="file-menu-btn" popovertarget="file-menu" aria-haspopup="menu">
ファイル
</button>
</nav>
<div id="file-menu" popover="auto" role="menu" aria-labelledby="file-menu-btn">
<button type="button" role="menuitem" tabindex="-1">新規作成</button>
<button type="button" role="menuitem" tabindex="-1">開く</button>
<button type="button" role="menuitem" tabindex="-1">保存</button>
<button type="button" role="menuitem" tabindex="-1" id="file-menu-close">閉じる</button>
</div>
<script>
const menu = document.getElementById('file-menu');
const menuBtn = document.getElementById('file-menu-btn');
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
const closeBtn = document.getElementById('file-menu-close');
menu.addEventListener('toggle', (event) => {
if (event.newState === 'open') {
items[0].focus();
} else {
menuBtn.focus();
}
});
menu.addEventListener('keydown', (event) => {
const index = items.indexOf(document.activeElement);
if (event.key === 'ArrowDown') {
event.preventDefault();
const next = items[(index + 1) % items.length];
next.focus();
}
if (event.key === 'ArrowUp') {
event.preventDefault();
const prev = items[(index - 1 + items.length) % items.length];
prev.focus();
}
if (event.key === 'Home') {
event.preventDefault();
items[0].focus();
}
if (event.key === 'End') {
event.preventDefault();
items[items.length - 1].focus();
}
});
closeBtn.addEventListener('click', () => {
menu.hidePopover();
});
items.forEach((item) => {
item.addEventListener('click', () => {
menu.hidePopover();
});
});
</script>
</body>
</html>
Popover API は メニューの開閉と Top Layer を担当し、矢印キー巡回は WAI-ARIA APG に沿って JS で補完します。2026 年 baseline でも「メニュー = popover だけで完成」ではなく、ロールとキーボードは引き続き開発者責任です。
実装例3:manual モードのカラーピッカー
外側クリックで消えては困る UI は popover="manual" を使います。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Manual Popover カラーピッカー</title>
<style>
.swatch-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 2rem;
}
.swatch {
width: 3rem;
height: 3rem;
border-radius: 0.5rem;
border: 2px solid #94a3b8;
cursor: pointer;
}
#color-picker {
margin: 0;
padding: 1rem;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
background: #fff;
box-shadow: 0 12px 32px rgb(0 0 0 / 0.15);
}
#color-picker fieldset {
border: none;
padding: 0;
margin: 0 0 1rem;
}
#color-picker label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.picker-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.picker-actions button {
font: inherit;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid #cbd5e1;
background: #f8fafc;
cursor: pointer;
}
.picker-actions button.primary {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
</style>
</head>
<body>
<div class="swatch-row">
<p>テーマカラー:</p>
<button
type="button"
class="swatch"
id="theme-swatch"
style="background: #3b82f6;"
popovertarget="color-picker"
popovertargetaction="show"
aria-label="色を選択"
></button>
</div>
<div id="color-picker" popover="manual" aria-label="カラーピッカー">
<fieldset>
<legend>プリセット</legend>
<label><input type="radio" name="preset" value="#3b82f6" checked> ブルー</label>
<label><input type="radio" name="preset" value="#22c55e"> グリーン</label>
<label><input type="radio" name="preset" value="#ef4444"> レッド</label>
<label><input type="radio" name="preset" value="#a855f7"> パープル</label>
</fieldset>
<div class="picker-actions">
<button type="button" id="picker-cancel" popovertarget="color-picker" popovertargetaction="hide">
キャンセル
</button>
<button type="button" class="primary" id="picker-apply">適用</button>
</div>
</div>
<script>
const swatch = document.getElementById('theme-swatch');
const picker = document.getElementById('color-picker');
const applyBtn = document.getElementById('picker-apply');
const cancelBtn = document.getElementById('picker-cancel');
const radios = picker.querySelectorAll('input[name="preset"]');
applyBtn.addEventListener('click', () => {
const selected = picker.querySelector('input[name="preset"]:checked');
if (selected) {
swatch.style.background = selected.value;
}
picker.hidePopover();
});
cancelBtn.addEventListener('click', () => {
picker.hidePopover();
});
document.addEventListener('click', (event) => {
if (!picker.matches(':popover-open')) return;
const target = event.target;
const clickedInside = picker.contains(target) || swatch.contains(target);
if (!clickedInside) {
// manual なので UA は自動では閉じない — 意図的に閉じない設計
}
});
</script>
</body>
</html>
manual では light dismiss が無効なため、キャンセル / 適用で明示的に hidePopover 相当の操作を提供します。外側クリックで閉じたい場合は JS で hidePopover() を呼ぶ分岐を足せますが、本例は「誤タップ防止」を優先しています。
実装例4:通知トースト(複数 manual popover)
トーストは manual + JS タイマーでスタック管理するパターンが一般的です。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Popover トースト</title>
<style>
body {
font-family: system-ui, sans-serif;
padding: 2rem;
}
#toast-host {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
pointer-events: none;
}
.toast {
margin: 0;
padding: 0.875rem 1rem;
min-width: 16rem;
max-width: 24rem;
border-radius: 0.5rem;
background: #0f172a;
color: #f8fafc;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.2);
pointer-events: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.toast.success { background: #166534; }
.toast.error { background: #991b1b; }
.toast button {
font: inherit;
color: inherit;
background: rgb(255 255 255 / 0.15);
border: none;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
}
</style>
</head>
<body>
<button type="button" id="show-success">成功トースト</button>
<button type="button" id="show-error">エラートースト</button>
<div id="toast-host" aria-live="polite" aria-relevant="additions"></div>
<script>
const host = document.getElementById('toast-host');
let toastId = 0;
function showToast(message, variant = 'default', durationMs = 4000) {
toastId += 1;
const id = `toast-${toastId}`;
const el = document.createElement('div');
el.id = id;
el.popover = 'manual';
el.className = `toast ${variant}`;
el.setAttribute('role', 'status');
const text = document.createElement('span');
text.textContent = message;
const close = document.createElement('button');
close.type = 'button';
close.textContent = '×';
close.setAttribute('aria-label', '閉じる');
close.addEventListener('click', () => dismissToast(el));
el.append(text, close);
host.appendChild(el);
el.showPopover();
const timer = setTimeout(() => dismissToast(el), durationMs);
el._dismissTimer = timer;
}
function dismissToast(el) {
if (el._dismissTimer) {
clearTimeout(el._dismissTimer);
}
if (el.matches(':popover-open')) {
el.hidePopover();
}
el.remove();
}
document.getElementById('show-success').addEventListener('click', () => {
showToast('保存しました', 'success');
});
document.getElementById('show-error').addEventListener('click', () => {
showToast('ネットワークエラーが発生しました', 'error', 6000);
});
</script>
</body>
</html>
各トーストは独立した Top Layer 要素です。aria-live="polite" 付きホストと role="status" でスクリーンリーダー通知を補完しています。大量同時表示は レイアウト・ペイントコスト に注意し、同時 3 件程度に制限するのが無難です。
実装例5:beforetoggle で認可チェック + フォールバック
本番寄りの統合例です。Feature detect、認可、prefers-reduced-motion、未対応時の <details> フォールバックを含みます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Popover 本番パターン</title>
<style>
.admin-bar {
padding: 1rem;
background: #f1f5f9;
border-bottom: 1px solid #e2e8f0;
}
#admin-panel {
margin: 0;
padding: 1.25rem;
width: min(90vw, 28rem);
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
background: #fff;
}
#admin-panel:popover-open {
animation: panel-in 160ms ease-out;
}
#admin-panel::backdrop {
background: rgb(15 23 42 / 0.4);
}
@keyframes panel-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
#admin-panel:popover-open { animation: none; }
}
.fallback-details summary {
cursor: pointer;
font-weight: 600;
}
.no-popover .popover-ui { display: none; }
.no-popover .fallback-details { display: block; }
.fallback-details { display: none; }
</style>
</head>
<body>
<div class="admin-bar popover-ui">
<button type="button" id="admin-trigger" popovertarget="admin-panel">
管理パネル
</button>
</div>
<div id="admin-panel" popover="auto">
<h2 style="margin: 0 0 0.75rem; font-size: 1.125rem;">サイト管理</h2>
<p style="margin: 0 0 1rem; color: #64748b; font-size: 0.875rem;">
キャッシュのパurge、メンテナンスモード切替ができます。
</p>
<button type="button" popovertarget="admin-panel" popovertargetaction="hide">
閉じる
</button>
</div>
<details class="fallback-details">
<summary>管理パネル(フォールバック)</summary>
<p>お使いのブラウザは Popover API 非対応のため、折りたたみ UI で表示しています。</p>
</details>
<script>
const supportsPopover =
typeof HTMLElement.prototype.showPopover === 'function';
if (!supportsPopover) {
document.documentElement.classList.add('no-popover');
}
const panel = document.getElementById('admin-panel');
const trigger = document.getElementById('admin-trigger');
function userCanAccessAdmin() {
return document.body.dataset.role === 'admin';
}
if (supportsPopover && panel) {
panel.addEventListener('beforetoggle', (event) => {
if (event.newState === 'open' && !userCanAccessAdmin()) {
event.preventDefault();
alert('管理者権限が必要です');
}
});
panel.addEventListener('toggle', (event) => {
if (event.newState === 'open') {
console.info('[analytics] admin_panel_open');
}
});
}
if (supportsPopover && trigger) {
trigger.addEventListener('click', () => {
if (!userCanAccessAdmin()) {
trigger.setAttribute('aria-expanded', 'false');
}
});
}
</script>
</body>
</html>
beforetoggle で 開く前 に権限を検証し、Analytics は toggle で記録します。HTML 中にユーザー入力を混ぜる場合は XSS 防御ガイド のエスケープ規則を必ず適用してください。
dialog 要素との比較
| 観点 | dialog / popover |
|---|---|
| モーダル性 | dialog + showModal(): 強制モーダル、背景 inert / フォーカストラップ / Esc で閉じる / dialog::backdrop |
| light dismiss | popover auto: 外側クリックで閉じる。dialog showModal(): 外側クリックでは通常閉じない |
| フォーカス | dialog showModal(): フォーカストラップ必須。popover: 完全トラップは仕様外(用途に応じて追加) |
| 同時表示 | 両方 Top Layer。auto popover は通常1つ、manual は複数可。dialog モーダルも Top Layer |
| 適した用途 | dialog: 確認・決済・法的同意 / popover: メニュー・パネル・トースト・ヒント |
「削除してよいですか?」の確認は dialog.showModal()、「ユーザーメニュー」は popover——2026 年の UI アーキテクチャではこの切り分けが標準です。
アクセシビリティ baseline(2026)
Popover API は アクセシビリティ API baseline の一部として、次が期待されます。
popovertarget付き Invoker — 開閉に連動してaria-expanded="true|false"を UA が更新- Esc キー —
auto/manualいずれでも popover を閉じられる - フォーカス — 開いた popover 内の最初のフォーカス可能要素へ移動(実装は UA 依存、複雑 UI は開発者補完)
- インライン表示 — popover は Top Layer のため、ズーム・スクロール容器から切り離され視認性が上がる
開発者が追加すべきこと
メニューなら role="menu" / menuitem と aria-haspopup="menu" を Invoker に付与する
ArrowUp/Down・Home/End で項目間を移動するキーボードハンドラを実装する
開いたら最初の項目、閉じたら Invoker にフォーカスを戻す(toggle イベントで制御)
動的コンテンツなら aria-live または role="status" で通知する
prefers-reduced-motion で ::backdrop / :popover-open のアニメーションを無効化する
コントラスト比 WCAG AA(4.5:1 以上)を ::backdrop 上のテキストで満たす
aria-modal="true" を popover に付けて dialog 相当にする手法は、フォーカストラップが自動では効かないため推奨されません。真のモーダルが必要なら dialog を使ってください。
Popover を「dialog の軽量版」と捉えて aria-modal だけ付けると、支援技術ユーザーはモーダル期待で操作するのにフォーカスが背景に逃げる——という アクセシビリティ負債 になります。モーダル要件なら dialog、非モーダルオーバーレイなら popover + 必要最小限の ARIA、と役割を一致させてください。
ネスト・複数 popover・hint モード
auto popover の排他
同一 document で popover="auto" が開いている状態で別の auto を開くと、以前の auto は自動で閉じます。これによりメニューとツールチップが同時に残る混乱を UA が防ぎます。
manual の複数表示
popover="manual" は排他されません。トーストスタックや、独立した複数パネルを同時表示できます。Top Layer の順序は 最後に show した要素が前面 です。
hint モード(2026 年の拡張)
popover="hint" は、別の popover(auto / manual / hint)が開いたときに 自動クローズ する軽量ヒント向けモードです。Chrome 133+、Safari 18.4+ で利用可能で、Firefox は未対応のため @supports または UA 判定で auto にフォールバックする設計が安全です。
<button popovertarget="term-hint">SLA</button>
<div id="term-hint" popover="hint">
Service Level Agreement — 可用性の保証水準
</div>
パフォーマンスと CRP への影響
Popover 自体は 既存 DOM ノードの表示切替 であり、新規 iframe や大規模サブツリー生成ほど重くはありません。ただし次の点は クリティカルレンダリングパス の観点で押さえておきます。
:popover-openアニメーション —transform/opacityを使えば Composite のみで済みやすい。width/heightアニメーションは Layout を誘発::backdropの全画面ペイント — 低スペック端末で初回 open 時にペイントコストが載る。duration を短く、不要なら backdrop を透明に- 大量 manual トースト — 同時に多数の Top Layer 要素はメモリとレイヤー管理コスト増。キューイングで上限を設ける
- beforetoggle での同期処理 — 重い認可 API を同期呼び出しすると INP 悪化。可能なら事前キャッシュ
/* 合成レイヤー向け — Layout を避ける */
[popover]:popover-open {
opacity: 1;
transform: translateY(0);
transition: opacity 160ms ease, transform 160ms ease;
}
[popover]:not(:popover-open) {
opacity: 0;
transform: translateY(-4px);
}
セキュリティ:XSS と Popover
Popover API は DOM 構造の表示制御 であり、注入防御にはなりません。次のパターンは XSS 攻撃と防御 で説明している通り、popover 利用時も同じリスクです。
- ユーザー名を popover 内に
innerHTMLで表示 → Stored XSS - URL パラメータから
popovertargetの id を組み立て → 意図しない UI 操作 - popover 内で
evalや inline event handler → CSP 違反・XSS 温床
textContent とテンプレートエンジンの自動エスケープを使い、CSP nonce による script 制限を維持してください。Popover は「安全な sandbox」ではありません。
フレームワーク統合(React / Astro)
React 19+
React 19 では popoverTarget(キャメルケース)が DOM プロパティとしてサポートされています。
function UserMenu() {
return (
<>
<button type="button" popoverTarget="user-menu-popover" aria-haspopup="menu">
アカウント
</button>
<div id="user-menu-popover" popover="auto" role="menu">
<button type="button" role="menuitem">プロフィール</button>
<button type="button" role="menuitem">ログアウト</button>
</div>
</>
);
}
クライアントコンポーネントで ref.current.showPopover() を呼ぶ場合、Strict Mode 二重マウントで状態がずれないよう toggle イベント で副作用を同期させます。
Astro
Astro では 静的 HTML として popover 属性をそのまま出力 できます。部分ハイドレーション(client:visible 等)する島だけ JS を載せ、メニュー開閉は宣言的 popovertarget のみ——という Islands Architecture との相性は良好です。hydration 前でも Invoker が機能するため、CRP 上の JS 依存を減らせます。
未対応ブラウザと polyfill
function initPopoverFallback() {
if (typeof HTMLElement.prototype.showPopover === 'function') {
return;
}
document.documentElement.classList.add('no-popover');
document.querySelectorAll('[popover]').forEach((el) => {
el.hidden = true;
el.removeAttribute('popover');
});
document.querySelectorAll('[popovertarget]').forEach((btn) => {
const targetId = btn.getAttribute('popovertarget');
const target = document.getElementById(targetId);
if (!target) return;
btn.addEventListener('click', () => {
const isHidden = target.hidden;
target.hidden = !isHidden;
btn.setAttribute('aria-expanded', String(!isHidden));
});
});
}
initPopoverFallback();
本番では @oddbird/popover-polyfill のように showPopover API をエミュレートする polyfill の利用も選択肢です。Bundle サイズと CRP の JS 実行時間をトレードオフで評価してください。
@supports selector(:popover-open) {
[popover]::backdrop {
background: rgb(0 0 0 / 0.3);
}
}Top Layer 非対応環境では通常の position: fixed スタイルを別途定義し、視覚的退行(graceful degradation)を許容します。
テストチェックリスト
E2E や手動 QA で確認すべき項目です。
- Invoker クリックで popover が開き、
aria-expandedがtrueになる - 外側クリック(auto)で light dismiss される
- Esc で閉じ、フォーカスが Invoker に戻る
manualでは外側クリックで閉じない- キーボードのみでメニュー項目を選択できる
prefers-reduced-motion: reduceでアニメーションが抑制される- Popover 非対応時にフォールバック UI が表示される
- popover 内にユーザー入力を表示する場合、XSS ペイロードが実行されない
Playwright 例:
import { test, expect } from '@playwright/test';
test('popover menu opens and light dismisses', async ({ page }) => {
await page.goto('/demo/popover-menu');
await page.getByRole('button', { name: 'ファイル' }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await page.locator('body').click({ position: { x: 8, y: 8 } });
await expect(menu).toBeHidden();
});
よくある落とし穴
| 症状 | 原因 | 対策 |
|---|---|---|
| popover が表示されない | display: none を CSS で上書きしすぎ | :popover-open 時のみ display: block 等を指定 |
| 外側クリックで閉じない | popover="manual" になっている | auto に変更、または JS で hide |
| 背景がスクロールする | popover は dialog と違い body scroll を止めない | 必要なら overflow: hidden を toggle で付与 |
| z-index が効かない | Top Layer は z-index 階層外 | z-index ではなく Top Layer の開閉順を利用 |
| フォーカスが背景に残る | メニュー APG 未実装 | toggle で focus 移動を実装 |
| Safari でアニメーションが効かない | @starting-style 未併用 | 2026 年は :popover-open + keyframes で十分な場合が多い |
関連記事
この記事は フロントエンド・CSS テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| CSS Grid vs Flexbox | CSS Grid vs Flexbox|どっちを使う?使い分けと実戦レイアウト |
| CSS Container Queries 完全ガイド | CSS Container Queries 完全ガイド|@container・size vs inline-size・コンポーネント単位レスポンシブ |
| CSSですりガラス(Glassmorphism)を作る方法 | CSSですりガラス(Glassmorphism)を作る方法|backdrop-filterの使い方 |
| CSSで浮き出るカードを作る | CSSで浮き出るカードを作る|きれいなbox-shadowの数値設定 |
| CSSの単位はpxとremどちらが良い? | CSSの単位はpxとremどちらが良い?|基準16pxの理由と使い分け |
| Fluid Typographyの作り方 | Fluid Typographyの作り方|vwとclampでレスポンシブ対応を自動化 |
| line-height計算 - 読みやすい行間をフォントサイズから算出 | line-height計算 - 読みやすい行間をフォントサイズから算出 |
| Tailwind CSSでデザインシステムの色を管理する方法 | Tailwind CSSでデザインシステムの色を管理する方法 |
| 色のコントラストチェック | 色のコントラストチェック|color contrastの使い方と注意点 |
| React SSR Hydration Mismatch 原因と修正 | React SSR Hydration Mismatch 原因と修正|Next.js・Astro対応ガイド |
| View Transitions API 完全ガイド | View Transitions API 完全ガイド|SPA・MPA ページ遷移と Astro/React 統合 |
| HTML Escape/Unescape | HTML Escape/Unescape|XSS 対策の基本 |
まとめ
HTML Popover API は、宣言的 markup(popover / popovertarget)+ Top Layer + light dismiss によって、ドロップダウン・パネル・トーストの定番実装を大幅に簡素化します。2026 年 6 月時点で主要ブラウザが揃い、新規コンポーネントでは 第一候補 として検討すべき API です。
設計上の指針を再掲します。
- 真のモーダルは
dialog、軽量オーバーレイはpopover - auto / manual / hint を UX に合わせて選び、light dismiss の副作用を理解する
- アクセシビリティ — UA baseline に加え、メニュー APG・live region を実装する
- セキュリティ — 中身の HTML は XSS 防御 を厳守
- パフォーマンス — アニメーションと同時表示数を抑え、CRP を計測する
Anchor Positioning API と組み合わせると、popover を Invoker 要素の近傍に自動配置できる(Chrome 125+ 等)。メニューの position 計算 JS を削減する次の標準機能として、Popover 導入後のリファクタ候補にしてください。