HTML Popover API 完全ガイド|popover・popovertarget・light dismiss・アクセシビリティ

(更新: 2026年6月22日 ) HTML Popover API アクセシビリティ CSS フロントエンド UX
結論
  • 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 年時点のブラウザ対応

機能ChromeSafariFirefoxEdge
popover + popovertarget114+17+125+114+
:popover-open / ::backdrop114+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 とは

Top Layer は dialog、popover: open な要素、Fullscreen API などが共有する描画スタックです。通常 DOM の z-index 階層とは独立しており、ページ内で最も上にペイントされます。複数の Top Layer 要素は後から開いたものが前面になり、開発者が z-index を競わせる必要がありません。詳細な描画タイミングは クリティカルレンダリングパス最適化 の「Composite」段階と関連します。

popover 属性の3モード

popover 属性は次の値を取ります(空文字 popoverauto と同義)。

挙動・用途
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 で分離制御するパターンは、ヘッダーの「開く」とパネル内の「閉じる」を独立させるときに有効です。

id の一意性と XSS

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 の一部として、次が期待されます。

  1. popovertarget 付き Invoker — 開閉に連動して aria-expanded="true|false" を UA が更新
  2. Esc キーauto / manual いずれでも popover を閉じられる
  3. フォーカス — 開いた popover 内の最初のフォーカス可能要素へ移動(実装は UA 依存、複雑 UI は開発者補完)
  4. インライン表示 — popover は Top Layer のため、ズーム・スクロール容器から切り離され視認性が上がる

開発者が追加すべきこと

1

メニューなら role="menu" / menuitem と aria-haspopup="menu" を Invoker に付与する

2

ArrowUp/Down・Home/End で項目間を移動するキーボードハンドラを実装する

3

開いたら最初の項目、閉じたら Invoker にフォーカスを戻す(toggle イベントで制御)

4

動的コンテンツなら aria-live または role="status" で通知する

5

prefers-reduced-motion で ::backdrop / :popover-open のアニメーションを無効化する

6

コントラスト比 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 による CSS 分岐
@supports selector(:popover-open) {
  [popover]::backdrop {
    background: rgb(0 0 0 / 0.3);
  }
}

Top Layer 非対応環境では通常の position: fixed スタイルを別途定義し、視覚的退行(graceful degradation)を許容します。

テストチェックリスト

E2E や手動 QA で確認すべき項目です。

  • Invoker クリックで popover が開き、aria-expandedtrue になる
  • 外側クリック(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 FlexboxCSS 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/UnescapeHTML Escape/Unescape|XSS 対策の基本

まとめ

HTML Popover API は、宣言的 markup(popover / popovertarget)+ Top Layer + light dismiss によって、ドロップダウン・パネル・トーストの定番実装を大幅に簡素化します。2026 年 6 月時点で主要ブラウザが揃い、新規コンポーネントでは 第一候補 として検討すべき API です。

設計上の指針を再掲します。

  1. 真のモーダルは dialog、軽量オーバーレイは popover
  2. auto / manual / hint を UX に合わせて選び、light dismiss の副作用を理解する
  3. アクセシビリティ — UA baseline に加え、メニュー APG・live region を実装する
  4. セキュリティ — 中身の HTML は XSS 防御 を厳守
  5. パフォーマンス — アニメーションと同時表示数を抑え、CRP を計測する
次のステップ

Anchor Positioning API と組み合わせると、popover を Invoker 要素の近傍に自動配置できる(Chrome 125+ 等)。メニューの position 計算 JS を削減する次の標準機能として、Popover 導入後のリファクタ候補にしてください。