ReactでPopover APIを使い、メニューを表示する方法
React 19 では、popover と popoverTarget を JSX にそのまま書けます。ボタンに popoverTarget、メニューに popover を付けるだけで、ボタンで開閉するメニューになります。メニューの外を押したときや Esc キーで閉じる処理、ほかの要素より手前に出す処理は、ブラウザが受け持ちます。React で書くのは、選んだ値の state と、選んだら閉じる処理くらいです。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chrome for Testing 153(Chromium)を Playwright 1.64.1 で操作して確かめました。Firefox や Safari、スマホの実機、スクリーンリーダーでの読み上げ、サーバーでのレンダリング(SSR)は試していません。
popover と popoverTarget を付けて、ボタンで開閉する
React の変更履歴によると、Popover API への対応は 19.0.0 で加わりました。検証環境の @types/react 19.3.0 にも、次の型がありました。
2940: popover?: "" | "auto" | "manual" | "hint" | undefined;
2941: popoverTargetAction?: "toggle" | "show" | "hide" | undefined;
2942: popoverTarget?: string | undefined;
例として、表示する猫を選ぶメニューを作ります。ボタンを押すとメニューが開き、項目を押すとボタンの文字が変わってメニューが閉じます。
import { useId, useRef, useState } from 'react';
import './menu.css';
const cats = ['ミケ', 'クロ', 'シロ'];
export function CatMenu() {
const menuId = useId();
const menuRef = useRef<HTMLDivElement>(null);
const [cat, setCat] = useState('ミケ');
const [open, setOpen] = useState(false);
function choose(name: string) {
setCat(name);
// 選んだらメニューを閉じる
menuRef.current?.hidePopover();
}
return (
<div className="toolbar">
<button type="button" className="menu-button" popoverTarget={menuId}>
表示する猫:{cat}
</button>
<div
id={menuId}
ref={menuRef}
popover="auto"
className="menu"
onToggle={(e) => setOpen(e.newState === 'open')}
>
{cats.map((name) => (
<button
key={name}
type="button"
className="menu-item"
aria-current={name === cat}
onClick={() => choose(name)}
>
{name}
</button>
))}
</div>
<p className="state">メニュー:{open ? '開いている' : '閉じている'}</p>
</div>
);
}
popoverTarget には、メニューの id を文字列で渡します。型も string です。id は useId で作ると、同じ部品を画面に何個置いても重なりません。React が描画したボタンとメニューの属性を調べると、次のようになっていました。
button: popovertarget="_r_0_" menu: id="_r_0_" popover="auto" popoverTargetElement が menu: true
ボタンには popovertarget 属性が付き、ブラウザもこのボタンの操作先をメニューとして扱っていました(popoverTargetElement がメニューを指していました)。MDN の button 要素のページにあるとおり、popovertargetaction を省くと、ボタンは開閉を切り替える toggle として動きます。ボタンを2回押した結果です。
1回目: 開いている true / 表示する猫:ミケ / メニュー:開いている / フォーカス メニューのボタン
2回目: 開いている false / 表示する猫:ミケ / メニュー:閉じている / フォーカス メニューのボタン
onClick も useState の開閉フラグもなしで、1回目で開き、2回目で閉じました。

項目を選んだら hidePopover で閉じる
項目のボタンを押しても、メニューは自動では閉じません。そこで choose の中で、メニューの要素の hidePopover() を呼んでいます。要素は useRef で受け取ります。「クロ」を押した結果です。
選んだ後: 開いている false / 表示する猫:クロ / メニュー:閉じている / フォーカス メニューのボタン
state が変わってボタンの文字が「クロ」になり、メニューは閉じました。押した項目はメニューと一緒に隠れましたが、フォーカスは失われず、メニューを開いたボタンに戻りました。
外側のクリックと Esc キーは、ブラウザが閉じてくれる
popover="auto"(値を省いた popover も同じ)のメニューは、外側を押すと閉じます。MDN の Popover API の使い方のページでは、これを「light dismiss」と呼んでいます。同じページには、Esc キーを押すなど、ブラウザが用意している操作でも閉じられると書かれています。メニューを開いてから外を押した結果と、キーボードで操作した結果です。
開いた: 開いている true / 表示する猫:クロ / メニュー:開いている / フォーカス メニューのボタン
外を押した後: 開いている false / 表示する猫:クロ / メニュー:閉じている / フォーカス BODY
Enter の後: 開いている true / 表示する猫:クロ / メニュー:開いている / フォーカス メニューのボタン
Tab の後: 開いている true / 表示する猫:クロ / メニュー:開いている / フォーカス 項目「ミケ」
Esc の後: 開いている false / 表示する猫:クロ / メニュー:閉じている / フォーカス メニューのボタン
ボタンにフォーカスを当てて Enter キーを押すと開き、Tab キーを押すとメニューの最初の項目にフォーカスが移りました。MDN のページにある、ポップオーバーのアクセシビリティの説明によると、開いたメニューは、フォーカスの順番でボタンの次に来るように扱われます。Esc キーを押すと閉じ、フォーカスはボタンに戻りました。
同じ説明には、aria-expanded を書かなくても、ブラウザがボタンとメニューを結びつけ、開いているかどうかを支援技術に伝える、とも書かれています。Chromium のアクセシビリティツリーで、ボタンの「展開」の状態を読みました。
閉じているとき ボタンの expanded: false / aria-expanded 属性: null
開いているとき ボタンの expanded: true / aria-expanded 属性: null
aria-expanded 属性は書いていませんが、メニューが開いているときは、ボタンが展開した状態として扱われていました。
auto のメニューは、ふつうは同時に1つしか開きません。別の auto のメニューを開くと、先に開いていたものは閉じます(MDN の同じ節)。検証では、メニューを2つ置き、片方を開いたまま、もう片方を showPopover() で開きました。外側のクリックで閉じたのと区別するため、クリックではなくメソッドで開いています。先に開いていたメニューは閉じました。
猫のメニュー true / 箱の中のメニュー false
position-area で、ボタンのすぐ下に出す
開いたメニューは、何も指定しないと画面の真ん中に出ます。position-area の指定を外して測ると、幅 480px・高さ 400px の画面で、幅 90px・高さ 130px のメニューが、左から 195px、上から 135px の位置に出ました。
position-area なし: メニュー left 195 top 135 / 画面の幅 480 高さ 400 / メニューの幅 90 高さ 130
ボタンの下に出すには、CSS の position-area を使います。
/* ボタンのすぐ下に、左端をそろえて出す */
.menu {
padding: 4px;
border: 1px solid #9aa5ab;
border-radius: 8px;
position-area: bottom span-right;
}
anchor-name や position-anchor は書いていません。MDN にある、ポップオーバーを呼び出し元のボタンに合わせて置く方法の説明によると、popovertarget でボタンとメニューを結びつけると、ボタンがメニューの暗黙のアンカーになり、position-area で置くときは、そのアンカーが自動で使われます。bottom span-right は、MDN の position-area のページにある 3×3 のマスで言うと、ボタンの下の段のうち、真ん中と右の2マスを使う指定です。結果として、メニューの左端がボタンの左端にそろいます。
ボタン left 8 bottom 52 / メニュー left 8 top 52
メニューの上端は、ボタンの下端(52px)に接し、左端(8px)もそろいました。
overflow: hidden の箱の中でも、メニューは切れない
MDN の Popover API の使い方のページによると、開いたポップオーバーは top layer に置かれ、ほかのページの中身より手前に出ます。高さ 60px で overflow: hidden の箱の中に、同じ作りのメニューを置いて試しました。
<div className="clip-box" style={{ overflow: 'hidden', height: 60, border: '1px dashed #9aa5ab' }}>
<button type="button" className="menu-button" popoverTarget={id}>
箱の中のメニュー
</button>
<div id={id} popover="auto" className="menu clip-menu">
<button type="button" className="menu-item">項目1</button>
<button type="button" className="menu-item">項目2</button>
<button type="button" className="menu-item">項目3</button>
</div>
</div>
箱の bottom 170 / メニューの bottom 283 / 「項目3」の位置で一番上にある要素: 項目3
メニューは箱の下端(170px)を越えて 283px まで出ていて、箱からはみ出た「項目3」の位置でも、一番上にある要素は「項目3」でした。切れずに表示され、ほかの要素にも隠れていませんでした。

開いているかどうかは onToggle で受け取る
メニューが開いているかどうかを React の state にしたいときは、onToggle を使います。外側を押したときや Esc キーで閉じたときも、ブラウザが閉じたことを state に反映できます。
onToggle={(e) => setOpen(e.newState === 'open')}
MDN の toggle イベントのページにあるとおり、開いたときは newState が open、閉じたときは closed になります。ここまでの出力の「メニュー:開いている/閉じている」が、この state です。ボタン、項目の hidePopover()、外側のクリック、Esc キーのどれで開閉しても、:popover-open で読んだ実際の状態と合っていました。
toggle イベントは、開閉が終わったあとに届きます。同じページにある、イベントがまとめられる場合の説明によると、同じ処理の中で続けて開閉すると(たとえば showPopover() の直後に hidePopover() を呼ぶと)、toggle イベントは1回しか届きません。検証では、操作のあと 50ms 待ってから state を読みました。開く前に何かをしたいときは、開く直前に届く beforetoggle イベントがあり、@types/react 19.3.0 には onBeforeToggle の型もありました。onBeforeToggle は、この記事では動かしていません。
ボタンに popoverTarget={id}、メニューに popover と同じ id を付けると、開閉、外側のクリックと Esc キーで閉じる処理、手前に出す処理はブラウザが受け持ちます。React では、項目を選んだら ref から hidePopover() を呼び、置き場所は CSS の position-area で決めます。開閉の状態が必要なら onToggle の newState を state に入れます。





