Reactで、右クリックしたときに独自のメニューを出す方法
右クリックで独自のメニューを出すには、onContextMenu で preventDefault() を呼んでブラウザのメニューを止め、イベントの clientX と clientY の位置に、position: fixed のメニューを出します。あとは、画面の端で切れないように位置を直す処理と、メニューの外を押したときなどに閉じる処理を足せば、ふだん使うアプリの右クリックメニューに近い動きになります。
ここでは、ファイルの一覧の行を右クリックすると「開く」「お気に入り」「削除」が出るメニューを作りました。キーボードの Shift+F10 やメニューキーで開いて、矢印キーで選べるようにもしています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で右クリックとキー入力をして、画面の大きさを 390×640px にして確かめました。メニューの項目名は短いものだけで試しています。Firefox と Safari、スマートフォンのブラウザ(長押しで開く場合)では試していません。StrictMode を付けた開発ビルドでも同じ操作をしたところ、出力は本番ビルドとまったく同じでした。

右クリックした位置を覚えて、ブラウザのメニューを止める
右クリックすると、その要素で contextmenu イベントが起きます。React では onContextMenu で受け取り、preventDefault() を呼ぶとブラウザのメニューが出なくなります。
メニューを出す位置には、イベントの clientX と clientY を使います。ページ全体の左上からではなく、今見えている画面の左上からの位置です。メニューを position: fixed で置けば、この値をそのまま left と top に使えます。
一覧のコンポーネントでは、どの行で開いたかと、開いた位置を state に持ちます。
type Menu = { x: number; y: number; name: string };
export function App() {
const [files, setFiles] = useState(['報告書.pdf', '写真.png', 'メモ.txt']);
const [stars, setStars] = useState<string[]>([]);
const [menu, setMenu] = useState<Menu | null>(null);
const [log, setLog] = useState('');
const opener = useRef<HTMLElement | null>(null);
function openMenu(e: React.MouseEvent<HTMLElement>, name: string) {
e.preventDefault(); // ブラウザのメニューを出さない
opener.current = e.currentTarget;
setMenu({ x: e.clientX, y: e.clientY, name });
}
// 閉じたら、開く前の行にフォーカスを戻す
const closeMenu = useCallback(() => {
setMenu(null);
opener.current?.focus();
}, []);
return (
<main>
<ul className="files">
{files.map((name) => (
<li
key={name}
tabIndex={0}
className={menu?.name === name ? 'selected' : undefined}
onContextMenu={(e) => openMenu(e, name)}
>
{stars.includes(name) ? `★ ${name}` : name}
</li>
))}
</ul>
<p data-testid="log">{log}</p>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
onClose={closeMenu}
items={[
{ label: '開く', onSelect: () => setLog(`「${menu.name}」を開く`) },
{
label: 'お気に入り',
onSelect: () =>
setStars((prev) =>
prev.includes(menu.name) ? prev.filter((s) => s !== menu.name) : [...prev, menu.name],
),
},
{
label: '削除',
onSelect: () => setFiles((prev) => prev.filter((f) => f !== menu.name)),
},
]}
/>
)}
</main>
);
}
開いている間は、右クリックした行に selected のクラスを付け、背景を薄い水色にしています。項目の onSelect は menu.name を使うので、どの行で開いたかによって、お気に入りや削除の対象が変わります。
右クリックした結果です。ログの [ページ] の行は、確かめるためにページへ別に付けたリスナーが、contextmenu のたびに書き出したものです。target には、イベントが起きた要素の文字を出しています。そのほかの行は、その場面で見る値だけに絞っています。メニュー は開いているメニューの位置(閉じていれば「なし」)、フォーカス はフォーカスのある要素、一覧 は一覧の行の文字、表示 は「開く」を選んだときに一覧の下に出る文字です。
行の上では defaultPrevented=true になり、メニューの左上が、右クリックした位置(clientX・clientY)と同じになりました。「開く」を選ぶとメニューが閉じて一覧の下に『「写真.png」を開く』と出て、「お気に入り」を選ぶと行に★が付きました。
[ページ] contextmenu button=2 clientX=80 clientY=68 target=写真.png defaultPrevented=true
「写真.png」を右クリック: メニュー left=80 top=68 right=230 bottom=190.78125
「開く」をクリック: メニュー なし / 表示 「写真.png」を開く
[ページ] contextmenu button=2 clientX=80 clientY=26 target=報告書.pdf defaultPrevented=true
「報告書.pdf」で「お気に入り」: 一覧 [★ 報告書.pdf, 写真.png, メモ.txt]
[ページ] contextmenu button=2 clientX=200 clientY=400 target=★ 報告書.pdf写真.pngメモ.txt「写真.png」を開く defaultPrevented=false
行のないところを右クリック: メニュー なし
行のないところ(target に一覧とログの文字がまとめて出ている行。行の外の要素です)では onContextMenu が呼ばれないので、defaultPrevented=false のままです。そこではブラウザのメニューが出ることになります(ヘッドレスの Chromium で確かめたので、ブラウザのメニューそのものは画面で見ていません)。
MDN の contextmenu イベントのページによると、Firefox では Shift キーを押しながら右クリックすると、contextmenu イベントが起きずにブラウザのメニューが出ます。この場合は preventDefault() でも止められません。
メニューを body に出し、画面の端では反対側に開く
メニューのコンポーネントは、createPortal で body の直下に出します。公式リファレンスにあるとおり、こうすると親に overflow: hidden などが付いていても、その影響を受けずにページの上に重ねられます。
右クリックした位置のまま出すと、画面の右端や下の端ではメニューが切れます。そこで、useLayoutEffect でメニューの大きさを測り、右に入らなければ左へ、下に入らなければ上へ開くようにしました。useLayoutEffect の中の処理と、その中で更新した state は、ブラウザが画面を描く前に反映されます。そのため、一度はみ出した位置に出てから動いて見えることはありません。
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
export type MenuItem = { label: string; onSelect: () => void };
type Props = {
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
};
const MARGIN = 8;
export function ContextMenu({ x, y, items, onClose }: Props) {
const ref = useRef<HTMLUListElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
// 描く前に大きさを測り、画面からはみ出すなら左や上に開く
useLayoutEffect(() => {
const { width, height } = ref.current!.getBoundingClientRect();
const left = x + width > window.innerWidth - MARGIN ? Math.max(MARGIN, x - width) : x;
const top = y + height > window.innerHeight - MARGIN ? Math.max(MARGIN, y - height) : y;
setPos({ left, top });
}, [x, y]);
// 開いたら、最初の項目にフォーカスを移す
useEffect(() => {
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}, [x, y]);
// (閉じる処理と、キーボードの処理は次の節)
return createPortal(
<ul
ref={ref}
role="menu"
className="context-menu"
style={{ left: pos.left, top: pos.top }}
onKeyDown={handleKeyDown}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item) => (
<li key={item.label} role="none">
<button
type="button"
role="menuitem"
tabIndex={-1}
onClick={() => {
onClose();
item.onSelect();
}}
>
{item.label}
</button>
</li>
))}
</ul>,
document.body,
);
}
CSS では、メニューに position: fixed と z-index を付けています。
.context-menu { position: fixed; z-index: 10; list-style: none; margin: 0; padding: 4px;
min-width: 140px; background: #fff; border: 1px solid #c9d3d8; border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.15); }
画面の大きさを 390×640px にして、端の近くで右クリックした結果です。メニューの幅は 150px で、右端の近く(clientX=365)では、右クリックした位置がメニューの右上になるように左へ開きました。右下の端では、右クリックした位置がメニューの右下になりました。
[ページ] contextmenu button=2 clientX=365 clientY=68 target=写真.png defaultPrevented=true
右の端(行の右端)を右クリック: メニュー left=215 top=68 right=365 bottom=190.78125
[ページ] contextmenu button=2 clientX=365 clientY=625 target=メモ.txt defaultPrevented=true
右下の端(行の右端、画面の下の端)を右クリック: メニュー left=215 top=502.21875 right=365 bottom=625
右下の端の確認では、一覧を画面の下に寄せるため、テストのときだけ一覧の上に余白を足しています。
メニューの外を押したときやスクロールしたときに閉じる
メニューを開いている間は、次のときに閉じます。
- メニューの外でマウスのボタンを押したとき
- Esc キーを押したとき
- ページや、ページの中のスクロールできる要素をスクロールしたとき
- 画面(ウィンドウ)の大きさが変わったとき
スクロールや大きさの変化で閉じるのは、clientX と clientY で決めた位置が、右クリックした行からずれてしまうためです。
// メニューの外を押す、Esc、スクロール、画面の大きさの変化で閉じる
useEffect(() => {
function handlePointerDown(e: PointerEvent) {
if (!ref.current?.contains(e.target as Node)) onClose();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', onClose, true);
window.addEventListener('resize', onClose);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', onClose, true);
window.removeEventListener('resize', onClose);
};
}, [onClose]);
onClose は、一覧の側で useCallback に包んでいます。公式リファレンスにある、依存の値が変わったときの動きのとおり、包まずに毎回新しい関数を渡すと、再描画のたびにこのリスナーを外して付け直すことになります。
scroll を受け取る addEventListener の第3引数 true は、検証環境の Chromium では外せませんでした。ページの中のスクロールできる要素(overflow: auto の div)を動かして比べたところ、true を付けた版(/)では閉じましたが、true を外した版(/nocapture/)では開いたままでした。
$ node test-box.mjs /
開いた直後のメニュー: 1
箱の scrollTop: 100 / ページの scrollY: 0 / 箱をスクロールしたあとのメニュー: 0
$ node test-box.mjs /nocapture/
開いた直後のメニュー: 1
箱の scrollTop: 100 / ページの scrollY: 0 / 箱をスクロールしたあとのメニュー: 1
ほかの閉じ方も確かめました。Esc で閉じたときは、右クリックした行にフォーカスが戻ります。メニューの外をクリックしたときは、フォーカスは body に移りました(クリックした場所に、フォーカスできる要素がないため)。メニューの上で右クリックしても閉じず、contextmenu も defaultPrevented=true になりました(メニューの ul にも onContextMenu を付けているため)。どれも、「写真.png」を右クリックして開き直してから試しています。
メニューの外をクリック: メニュー なし / フォーカス body
Esc: メニュー なし / フォーカス li「写真.png」
ホイールでスクロール: メニュー なし / フォーカス li「写真.png」
画面の大きさを変える: メニュー なし / フォーカス li「写真.png」
[ページ] contextmenu button=2 clientX=155 clientY=129 target=お気に入り defaultPrevented=true
メニューの上で右クリック: メニュー left=80 top=68 right=230 bottom=190.78125 / フォーカス menuitem「お気に入り」
開いたまま別の行を右クリックすると、メニューはその位置に移りました。
[ページ] contextmenu button=2 clientX=50 clientY=68 target=写真.png defaultPrevented=true
「写真.png」を右クリック: メニュー left=50 top=68 right=200 bottom=190.78125
[ページ] contextmenu button=2 clientX=220 clientY=121 target=メモ.txt defaultPrevented=true
続けて「メモ.txt」を右クリック: メニュー left=220 top=121 right=370 bottom=243.78125
開き直したメニューで項目を選ぶと、対象はあとから右クリックした行になりました(右クリックした位置が右端に近いので、メニューは左へ開いています)。次の出力では、selected のクラスも「メモ.txt」の行に移り、「開く」で「メモ.txt」が開かれています。
[ページ] contextmenu button=2 clientX=80 clientY=26 target=報告書.pdf defaultPrevented=true
「報告書.pdf」を右クリック: メニュー left=80 top=26 right=230 bottom=148.78125
[ページ] contextmenu button=2 clientX=300 clientY=68 target=メモ.txt defaultPrevented=true
selected の行: メモ.txt
続けて「メモ.txt」を右クリック: メニュー left=150 top=68 right=300 bottom=190.78125
「開く」をクリック: メニュー なし / 表示 「メモ.txt」を開く
キーボードで開いて、矢印キーで選ぶ
マウスを使わない人のために、キーボードでも開けるようにします。一覧の行に tabIndex={0} を付けてフォーカスできるようにすると、行にフォーカスがあるときに Shift+F10 やメニューキーを押すだけで、同じ contextmenu イベントが起きました。追加の処理は要りません。
「報告書.pdf」にフォーカス: フォーカス li「★ 報告書.pdf」
「報告書.pdf」の行の位置: left=20 right=370 top=16 bottom=58.59375
[ページ] contextmenu button=-1 clientX=195 clientY=37 target=★ 報告書.pdf defaultPrevented=true
Shift+F10 を押す: メニュー left=195 top=37 right=345 bottom=159.78125 / フォーカス menuitem「開く」
[ページ] contextmenu button=-1 clientX=195 clientY=37 target=★ 報告書.pdf defaultPrevented=true
ContextMenu を押す: メニュー left=195 top=37 right=345 bottom=159.78125 / フォーカス menuitem「開く」
ログの ContextMenu は、Playwright でのメニューキーの名前です。キーボードで開いたときは button=-1 になり、clientX と clientY は行の真ん中(左右も上下も中央)の位置でした。行の位置は left=20 right=370 top=16 bottom=58.59375 だったので、真ん中は(195, 37)です。そのため、メニューは行の真ん中から開きます。
MDN には、メニューキーで開いたとき、ブラウザのメニューはフォーカスのある要素の左下に出ると書かれています。一方、検証環境の Chromium で contextmenu イベントに入っていた位置は、行の真ん中でした。ほかのブラウザでの値は確かめていません。
メニューの中のキー操作は、WAI-ARIA の Authoring Practices にある、メニューのキーボード操作に合わせました。開いたら最初の項目にフォーカスを移し、上下の矢印キーで項目を移ります。Tab ではメニューの中を移らず、メニューを閉じます。各項目は role="menuitem" の button なので、Enter で選べます。
// 上下の矢印キー、Home、End で項目を移る(端まで来たら反対の端へ)
function handleKeyDown(e: React.KeyboardEvent) {
const els = [...ref.current!.querySelectorAll<HTMLElement>('[role="menuitem"]')];
const i = els.indexOf(document.activeElement as HTMLElement);
let next = -1;
if (e.key === 'ArrowDown') next = (i + 1) % els.length;
else if (e.key === 'ArrowUp') next = (i - 1 + els.length) % els.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = els.length - 1;
// Tab はメニューを閉じて、ページの次の要素へ進ませる
else if (e.key === 'Tab') onClose();
if (next >= 0) {
e.preventDefault();
els[next].focus();
}
}
項目の tabIndex={-1} は、Tab キーで項目に止まらないようにするためのものです。フォーカスは、矢印キーの処理から focus() で移します。
Shift+F10 で開いてからキーを押した結果です。最後の項目で下の矢印キーを押すと最初の項目へ、最初の項目で上の矢印キーを押すと最後の項目へ移りました。Tab を押すとメニューが閉じ、フォーカスは次の行の「写真.png」に進みました。onClose が開く前の行にフォーカスを戻し、そこから Tab キーの本来の動きで次へ進んだためです。
ArrowDown: フォーカス menuitem「お気に入り」
ArrowDown: フォーカス menuitem「削除」
ArrowDown: フォーカス menuitem「開く」
ArrowUp: フォーカス menuitem「削除」
End: フォーカス menuitem「削除」
Home: フォーカス menuitem「開く」
Tab: メニュー なし / フォーカス li「写真.png」
もう一度「報告書.pdf」で開き、「お気に入り」に移って Enter を押すと、★が外れてフォーカスが行に戻りました。一方、「削除」を選んだときは、戻す先の行が消えるので、フォーカスは body になりました。削除のあとに次の行へフォーカスを移す処理は、この記事では作っていません。
「お気に入り」で Enter: フォーカス li「報告書.pdf」 / 一覧 [報告書.pdf, 写真.png, メモ.txt]
「写真.png」で「削除」: フォーカス body / 一覧 [報告書.pdf, メモ.txt]
右クリックメニューに要る処理は、次の4つでした。onContextMenu で preventDefault() を呼んで位置を覚えること、createPortal で body の直下に出し、useLayoutEffect で測って画面の端では反対側に開くこと、外のクリックや Esc、スクロール、画面の大きさの変化で閉じること(Esc などでは開く前の行にフォーカスを戻す)、矢印キーで項目を移れるようにすることです。





