ポップオーバー(Popover API)— 行メニューをブラウザ標準APIで実装する

通知・オーバーレイ 初級

このコンポーネントについて

ポップオーバーはボタンクリックで情報パネルやメニューを画面最前面に表示するUIパターンです。 ブラウザ標準のPopover APIを使うと、popover 属性と popovertarget 属性を書くだけでJavaScriptなしでも開閉でき、クリック外で自動的に閉じる「light dismiss」も標準で備わっています。 従来のカスタム実装で必要だった「外クリックで閉じる処理」や「z-index管理」が不要になります。 このページではテーブル行の「⋯」ボタンからアクションメニューを表示する業務アプリ向けの実装例を2パターン紹介します。

  • Pattern 1 — HTMLのみ(JSゼロ)popovertarget 属性を書くだけ。JavaScriptは一切不要。メニューはブラウザのデフォルト位置(画面中央)に表示される
  • Pattern 2 — JS位置調整 + CSSアニメーションbeforetoggle イベントで getBoundingClientRect() を使いボタン直下に配置。@starting-style + transition でフェードイン・スライドアニメーション付き

実装のポイント・注意点

popover="auto" を付けた要素は「light dismiss」が自動で有効になります。メニュー外をクリックするだけで閉じるため、自前でクリックイベントを監視する必要がありません。また同じ popover="auto" を持つ要素は同時に1つしか開けない「排他制御」が働くので、「他のメニューを閉じるJS」も不要です。

ポップオーバーは「Top Layer」と呼ばれる特殊なレイヤーに表示されます。これはあらゆる z-index の上に描画されるため、テーブルの overflow: hidden 環境や z-index の積み重ね問題を気にせずに使えます。

Pattern 2のアニメーションには @starting-style が必要です。:popover-open だけでは「表示→表示」のトランジションしか定義されず、「非表示→表示」(開く瞬間)のアニメーションが発火しません。@starting-style で表示直前の初期値を別途定義することでエントリアニメーションが動きます。退場アニメーションには transitiondisplay 0.15s allow-discreteoverlay 0.15s allow-discrete を加えます。これを省くと閉じる瞬間に要素がアニメーションなしで消えます。

Pattern 1でHTMLのみにする場合、メニューはブラウザのデフォルト位置(画面中央)に表示されます。JavaScript不要という利点がある一方、ボタン近くにメニューを出すには Pattern 2のようにJS位置調整が必要です。現時点ではCSS Anchor Positioning(Chrome 125+)を使えばJSなしで位置調整できますが、まだ普及途上のAPIです。

ブラウザ対応: Chrome 114+ / Safari 17+ / Firefox 125+ で動作します。まだ古いブラウザには対応していないため、本番利用時はサポートブラウザを確認してください。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

Pattern 1 — HTMLのみ(JSゼロ)

メニューはブラウザのデフォルト位置(画面中央)に表示されます

顧客名 ステータス 担当者
株式会社 ABC 対応中 山田 花子
有限会社 XYZ 完了 鈴木 一郎
合同会社 DEF 保留中 田中 次郎

Pattern 2 — JS位置調整 + CSSアニメーション

顧客名 ステータス 担当者
株式会社 ABC 対応中 山田 花子
有限会社 XYZ 完了 鈴木 一郎
合同会社 DEF 保留中 田中 次郎

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ポップオーバー サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<!-- ===== Pattern 1: HTMLのみ(popovertarget 属性と popover 属性だけ。JSゼロ) ===== -->
<!-- メニューはブラウザのデフォルト位置(画面中央)に表示されます。 -->
<section class="section">
  <h2 class="section-title">Pattern 1 — HTMLのみ(JSゼロ)</h2>
  <table class="pp-table">
    <thead>
      <tr>
        <th>顧客名</th><th>ステータス</th><th>担当者</th><th></th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>株式会社 ABC</td>
        <td><span class="pp-badge pp-badge--active">対応中</span></td>
        <td>山田 花子</td>
        <td class="pp-action-cell">
          <button class="pp-trigger" popovertarget="pp-menu-1" type="button" aria-label="アクションメニューを開く">⋯</button>
          <div class="pp-menu" popover id="pp-menu-1" role="menu">
            <button class="pp-menu-item" type="button" role="menuitem">✏️ 編集</button>
            <button class="pp-menu-item" type="button" role="menuitem">📋 複製</button>
            <hr class="pp-divider">
            <button class="pp-menu-item pp-menu-item--danger" type="button" role="menuitem">🗑 削除</button>
          </div>
        </td>
      </tr>
      <tr>
        <td>有限会社 XYZ</td>
        <td><span class="pp-badge pp-badge--done">完了</span></td>
        <td>鈴木 一郎</td>
        <td class="pp-action-cell">
          <button class="pp-trigger" popovertarget="pp-menu-2" type="button" aria-label="アクションメニューを開く">⋯</button>
          <div class="pp-menu" popover id="pp-menu-2" role="menu">
            <button class="pp-menu-item" type="button" role="menuitem">✏️ 編集</button>
            <button class="pp-menu-item" type="button" role="menuitem">📋 複製</button>
            <hr class="pp-divider">
            <button class="pp-menu-item pp-menu-item--danger" type="button" role="menuitem">🗑 削除</button>
          </div>
        </td>
      </tr>
      <tr>
        <td>合同会社 DEF</td>
        <td><span class="pp-badge pp-badge--hold">保留中</span></td>
        <td>田中 次郎</td>
        <td class="pp-action-cell">
          <button class="pp-trigger" popovertarget="pp-menu-3" type="button" aria-label="アクションメニューを開く">⋯</button>
          <div class="pp-menu" popover id="pp-menu-3" role="menu">
            <button class="pp-menu-item" type="button" role="menuitem">✏️ 編集</button>
            <button class="pp-menu-item" type="button" role="menuitem">📋 複製</button>
            <hr class="pp-divider">
            <button class="pp-menu-item pp-menu-item--danger" type="button" role="menuitem">🗑 削除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
</section>

<!-- ===== Pattern 2: JS位置調整 + CSSアニメーション ===== -->
<section class="section">
  <h2 class="section-title">Pattern 2 — JS位置調整 + CSSアニメーション</h2>
  <table class="pp2-table">
    <thead>
      <tr>
        <th>顧客名</th><th>ステータス</th><th>担当者</th><th></th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>株式会社 ABC</td>
        <td><span class="pp2-badge pp2-badge--active">対応中</span></td>
        <td>山田 花子</td>
        <td class="pp2-action-cell">
          <button class="pp2-trigger" popovertarget="pp2-menu-1" type="button" aria-label="アクションメニューを開く">⋯</button>
          <div class="pp2-menu" popover id="pp2-menu-1" role="menu">
            <button class="pp2-menu-item" type="button" role="menuitem">✏️ 編集</button>
            <button class="pp2-menu-item" type="button" role="menuitem">📋 複製</button>
            <hr class="pp2-divider">
            <button class="pp2-menu-item pp2-menu-item--danger" type="button" role="menuitem">🗑 削除</button>
          </div>
        </td>
      </tr>
      <tr>
        <td>有限会社 XYZ</td>
        <td><span class="pp2-badge pp2-badge--done">完了</span></td>
        <td>鈴木 一郎</td>
        <td class="pp2-action-cell">
          <button class="pp2-trigger" popovertarget="pp2-menu-2" type="button" aria-label="アクションメニューを開く">⋯</button>
          <div class="pp2-menu" popover id="pp2-menu-2" role="menu">
            <button class="pp2-menu-item" type="button" role="menuitem">✏️ 編集</button>
            <button class="pp2-menu-item" type="button" role="menuitem">📋 複製</button>
            <hr class="pp2-divider">
            <button class="pp2-menu-item pp2-menu-item--danger" type="button" role="menuitem">🗑 削除</button>
          </div>
        </td>
      </tr>
      <tr>
        <td>合同会社 DEF</td>
        <td><span class="pp2-badge pp2-badge--hold">保留中</span></td>
        <td>田中 次郎</td>
        <td class="pp2-action-cell">
          <button class="pp2-trigger" popovertarget="pp2-menu-3" type="button" aria-label="アクションメニューを開く">⋯</button>
          <div class="pp2-menu" popover id="pp2-menu-3" role="menu">
            <button class="pp2-menu-item" type="button" role="menuitem">✏️ 編集</button>
            <button class="pp2-menu-item" type="button" role="menuitem">📋 複製</button>
            <hr class="pp2-divider">
            <button class="pp2-menu-item pp2-menu-item--danger" type="button" role="menuitem">🗑 削除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
</section>

<script src="./script.js"></script>
</body>
</html>
:root {
  --pp-border: #E5E7EB;
  --pp-radius: 8px;
  --pp-red:    #EF4444;
  --pp-menu-width: 160px;
}

*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: sans-serif;
  background: #F9FAFB;
  margin: 0;
  padding: 24px;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.section { max-width: 560px; }

.section-title {
  font-size: 14px;
  font-weight: 700;
  color: #374151;
  margin: 0 0 12px;
}

/* ===== テーブル(Pattern 1 / 2 共通) ===== */
.pp-table,
.pp2-table {
  width: 100%;
  border-collapse: collapse;
  background: #fff;
  border: 1.5px solid var(--pp-border);
  border-radius: var(--pp-radius);
  overflow: hidden;
}

.pp-table th, .pp2-table th {
  background: #F9FAFB;
  color: #6B7280;
  font-size: 12px;
  font-weight: 600;
  padding: 10px 14px;
  text-align: left;
  border-bottom: 1px solid var(--pp-border);
}

.pp-table td, .pp2-table td {
  font-size: 14px;
  padding: 12px 14px;
  border-bottom: 1px solid var(--pp-border);
  color: #374151;
}

.pp-table tr:last-child td,
.pp2-table tr:last-child td {
  border-bottom: none;
}

.pp-action-cell, .pp2-action-cell {
  width: 48px;
  text-align: center;
}

/* ⋯ トリガーボタン */
.pp-trigger, .pp2-trigger {
  width: 32px;
  height: 32px;
  background: transparent;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 18px;
  color: #9CA3AF;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s, color 0.15s;
}
.pp-trigger:hover, .pp2-trigger:hover {
  background: #F3F4F6;
  color: #374151;
}

/* ステータスバッジ */
.pp-badge, .pp2-badge {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
}
.pp-badge--active, .pp2-badge--active { background: #DBEAFE; color: #1D4ED8; }
.pp-badge--done,   .pp2-badge--done   { background: #D1FAE5; color: #065F46; }
.pp-badge--hold,   .pp2-badge--hold   { background: #F3F4F6; color: #374151; }

/* ===== Pattern 1 — メニュー(ブラウザのデフォルト位置) ===== */
/* UAの border だけ上書きする。位置は UA スタイル(画面中央)のまま。 */
.pp-menu {
  /* CSSリセットで margin: 0 が当たる場合に備えて明示する(UA の中央寄せを保つため) */
  margin: auto;
  padding: 6px 0;
  background: #fff;
  border: 1.5px solid var(--pp-border);
  border-radius: var(--pp-radius);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
  min-width: var(--pp-menu-width);
}

.pp-menu-item {
  display: block;
  width: 100%;
  padding: 8px 14px;
  font-size: 14px;
  color: #374151;
  background: transparent;
  border: none;
  text-align: left;
  cursor: pointer;
  font-family: sans-serif;
  transition: background 0.1s;
}
.pp-menu-item:hover { background: #F3F4F6; }
.pp-menu-item--danger { color: var(--pp-red); }
.pp-menu-item--danger:hover { background: #FEE2E2; }

.pp-divider {
  border: none;
  border-top: 1px solid var(--pp-border);
  margin: 4px 0;
}

/* ===== Pattern 2 — メニュー(JS で top / left を動的にセット) ===== */
.pp2-menu {
  padding: 6px 0;
  background: #fff;
  border: 1.5px solid var(--pp-border);
  border-radius: var(--pp-radius);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
  min-width: var(--pp-menu-width);

  /* 閉じた状態のスタイル */
  opacity: 0;
  transform: translateY(-4px);
  /* display/overlay を allow-discrete で遷移させると退場アニメーションが機能する */
  transition:
    opacity 0.15s ease,
    transform 0.15s ease,
    display 0.15s allow-discrete,
    overlay 0.15s allow-discrete;
}

/* 開いた状態のスタイル */
.pp2-menu:popover-open {
  opacity: 1;
  transform: translateY(0);
}

/* エントリアニメーション:「非表示→表示」直前の初期スタイルを定義する */
@starting-style {
  .pp2-menu:popover-open {
    opacity: 0;
    transform: translateY(-4px);
  }
}

.pp2-menu-item {
  display: block;
  width: 100%;
  padding: 8px 14px;
  font-size: 14px;
  color: #374151;
  background: transparent;
  border: none;
  text-align: left;
  cursor: pointer;
  font-family: sans-serif;
  transition: background 0.1s;
}
.pp2-menu-item:hover { background: #F3F4F6; }
.pp2-menu-item--danger { color: var(--pp-red); }
.pp2-menu-item--danger:hover { background: #FEE2E2; }

.pp2-divider {
  border: none;
  border-top: 1px solid var(--pp-border);
  margin: 4px 0;
}
// Pattern 2: メニューをトリガーボタン直下に動的配置する

document.querySelectorAll('.pp2-menu').forEach(function(menu) {
  // beforetoggle: popover が開く直前に発火する
  menu.addEventListener('beforetoggle', function(e) {
    if (e.newState !== 'open') return;

    // popovertarget 属性でこのメニューを指定しているボタンを取得
    var trigger = document.querySelector('[popovertarget="' + menu.id + '"]');
    if (!trigger) return;

    // ボタンのビューポート座標を取得
    var rect = trigger.getBoundingClientRect();

    // メニューをボタン直下・右揃えに配置(メニュー幅 160px 固定)
    menu.style.top    = (rect.bottom + 6) + 'px';
    menu.style.left   = (rect.right - 160) + 'px';
    menu.style.margin = '0'; // UA の margin: auto を上書き
  });
});

AI用プロンプト

各パターンのプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や要件の追記など、用途に合わせて変えて使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

Pattern 1 — HTMLのみ(JSゼロ)

# ポップオーバー(HTMLのみ版)作成依頼

## 概要
ブラウザ標準のPopover APIを使い、テーブル行の「…」ボタンからアクションメニューを表示してください。
JavaScriptを一切使わず、HTML属性のみで実装します。

## 要件
- 顧客管理テーブルを3行用意する(顧客名・ステータス・担当者・操作列)
- 各行の右端に「⋯」ボタンを配置し、クリックでアクションメニューをポップオーバー表示する
- メニュー項目は「✏️ 編集」「📋 複製」「🗑 削除(赤文字)」の3つ
- メニュー外をクリックすると自動で閉じる(light dismiss)
- 1つのメニューを開くと他のメニューは自動で閉じる(排他制御)
- popovertarget 属性と popover 属性のみ使用する。JavaScriptは一切書かない

## 技術仕様
- HTML / CSS のみで実装(JavaScriptは使用しない)
- 外部ライブラリ:なし
- レスポンシブ対応:不要

## 動作詳細
各行のボタンに popovertarget="menu-{n}" を付与し、対応する <div popover id="menu-{n}"> をボタン直後に記述する。
popover="auto" を使うことで排他制御と light dismiss を有効にする。
メニューの表示位置はブラウザのデフォルト(画面中央)のまま使う(JSなしのため)。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。

Pattern 2 — JS位置調整 + CSSアニメーション

# ポップオーバー(JS位置調整 + アニメーション版)作成依頼

## 概要
ブラウザ標準のPopover APIを使い、テーブル行の「…」ボタン直下にアクションメニューを表示してください。
JavaScriptで位置調整をおこない、CSSアニメーションで滑らかに開閉します。

## 要件
- 顧客管理テーブルを3行用意する(顧客名・ステータス・担当者・操作列)
- 各行の「⋯」ボタンをクリックするとボタンの直下にメニューがスライドしながら表示される
- メニュー項目は「✏️ 編集」「📋 複製」「🗑 削除(赤文字)」の3つ
- メニューの幅は160pxに固定し、ボタン右端に合わせて右揃えにする
- 開くときはフェードイン + 上から下へのスライド(translateY(-4px) → 0)
- 閉じるときも同じアニメーションで退場する(display / overlay の allow-discrete を使う)
- メニュー外をクリックすると自動で閉じる(light dismiss)

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:不要

## 動作詳細
beforetoggle イベントで newState === 'open' のときにトリガーボタンの getBoundingClientRect() を取得し、
menu.style.top と menu.style.left に position: fixed の座標をセットする。
@starting-style でエントリアニメーションの初期値を定義し、
transition の display 0.15s allow-discrete と overlay 0.15s allow-discrete で退場アニメーションを有効にする。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。