Reactで、入れ子になったドロップダウンメニューを作る方法
入れ子のドロップダウンメニューは、メニューの中身を children を持つ木の形のデータで書き、メニューを描く関数の中で同じ関数をもう一度呼んで(再帰で)作ります。どのサブメニューが開いているかは、開いている親の項目の id を上の階層から並べた配列1つで持ちます。こうすると、同じ階層で開いているサブメニューは必ず1つになり、「1つ上の階層に戻る」は配列の末尾を削るだけで済みます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium にポインターの移動、クリック、キー入力を送って確かめました。アプリ全体を StrictMode で囲んだ場合も、同じ操作で同じ結果になりました。
Firefox と Safari、タッチ操作の端末、スクリーンリーダーでは試していません。画面の右端でサブメニューを左側に開き直す処理も、この記事のコードには入れていません。

メニューの中身を、children を持つ木の形で書く
メニューの項目は、次の形のオブジェクトの配列で渡します。children を持つ項目が、サブメニューを開く親の項目です。
export type MenuItem = {
id: string;
label: string;
children?: MenuItem[]; // あれば、この項目からサブメニューが開く
};
export const menuItems: MenuItem[] = [
{ id: 'new', label: '新規作成' },
{ id: 'open', label: '開く' },
{
id: 'export',
label: '書き出す',
children: [
{ id: 'pdf', label: 'PDF' },
{
id: 'image',
label: '画像',
children: [
{ id: 'png', label: 'PNG' },
{ id: 'jpeg', label: 'JPEG' },
{ id: 'webp', label: 'WebP' },
],
},
{ id: 'text', label: 'テキスト' },
],
},
{
id: 'share',
label: '共有',
children: [
{ id: 'copy-link', label: 'リンクをコピー' },
{ id: 'mail', label: 'メールで送る' },
],
},
{ id: 'delete', label: '削除' },
];
children の中にも同じ形の項目が入るので、階層は何段でも足せます。この例では「書き出す」の下の「画像」が、3段目のメニューを持っています。
開いているサブメニューを、親の id の配列で持つ
コンポーネントの state は、ボタンから開く一番上のメニューが開いているかどうか(open)と、開いているサブメニューの親の id の配列(openPath)の2つです。
const [open, setOpen] = useState(false);
// 開いているサブメニューの親の id を、上の階層から順に持つ。[] ならサブメニューは開いていない
const [openPath, setOpenPath] = useState<string[]>([]);
「書き出す」の下の「画像」まで開いているときは、openPath は ['export', 'image'] になります。メニューを描く renderMenu は、level 階層目の項目のうち openPath[level] と id が同じものだけを開いた状態にして、その下に自分自身を呼んでサブメニューを描きます。
const renderMenu = (list: MenuItem[], level: number, parents: MenuItem[]) => {
const parent = parents.at(-1) ?? null;
return (
<ul
role="menu"
id={menuId(parent ? parent.id : 'root')}
aria-labelledby={parent ? itemId(parent.id) : `${baseId}-button`}
className={level === 0 ? 'menu' : 'menu submenu'}
>
{list.map((item) => {
const expanded = openPath[level] === item.id;
return (
<li role="none" key={item.id}>
<div
role="menuitem"
id={itemId(item.id)}
tabIndex={-1}
aria-haspopup={item.children ? 'menu' : undefined}
aria-expanded={item.children ? expanded : undefined}
aria-controls={item.children && expanded ? menuId(item.id) : undefined}
onPointerEnter={(e) => handlePointerEnter(level, item, e.currentTarget)}
onKeyDown={(e) => handleKeyDown(e, level, item, parents)}
onClick={() => {
if (!item.children) return select([...parents, item]);
// 親の項目を押したときは、待たずにサブメニューを開く
cancelTimer();
setOpenPath((path) => [...path.slice(0, level), item.id]);
}}
>
{item.label}
{item.children && <span aria-hidden="true">▸</span>}
</div>
{item.children && expanded && renderMenu(item.children, level + 1, [...parents, item])}
</li>
);
})}
</ul>
);
};
ul と li の組み立ては、WAI-ARIA のメニューとメニューバーのパターンに合わせました。このパターンでは、サブメニューの menu を、同じ menu の中で親の menuitem のすぐ後ろに置きます。親の menuitem には、aria-haspopup に menu を付け、サブメニューが見えているかどうかを aria-expanded の true と false で表します。li は並べるためだけの要素なので、none ロールを付けて、リストの項目としての意味をアクセシビリティツリーに出さないようにしています。
「書き出す」を開いたところで、Playwright でアクセシビリティツリーを書き出すと、次のようになりました。サブメニューの名前には、aria-labelledby で指した親の項目の文字が使われています。
- button "メニュー" [expanded]
- menu "メニュー":
- menuitem "新規作成"
- menuitem "開く"
- menuitem "書き出す" [expanded]
- menu "書き出す":
- menuitem "PDF"
- menuitem "画像"
- menuitem "テキスト"
- menuitem "共有"
- menuitem "削除"
サブメニューを親の項目の右に出すのは、CSS の役目です。li を position: relative にしておき、サブメニューの ul を left: 100% で li の右端に置きます。
.menu li { position: relative; }
.submenu { top: -5px; left: 100%; margin: 0 0 0 2px; }
ポインターが乗ったら、少し待ってからサブメニューを切り替える
ポインターが項目に乗ったらすぐにサブメニューを切り替える作りでは、斜めに動かしたときに困ります。「書き出す」から、そのサブメニューの下のほうにある「テキスト」へ斜めに動かすと、途中で1つ下の「共有」の上を通ります。その瞬間に「共有」のサブメニューに切り替わり、目当ての「テキスト」が消えてしまいます。

そこで、ポインターが別の項目に乗ってからサブメニューを切り替えるまでに、少し待つようにします。待っている間にポインターがサブメニューの中の項目に乗れば、その時点でタイマーを取り消すので、通り過ぎただけの「共有」は開きません。
// level 階層目の項目 item に、ポインターが乗ったとき
const handlePointerEnter = (level: number, item: MenuItem, el: HTMLElement) => {
el.focus();
cancelTimer();
// 親の項目ならそのサブメニューを開き、そうでなければ、この階層より下のサブメニューを閉じる
const update = () =>
setOpenPath((path) => (item.children ? [...path.slice(0, level), item.id] : path.slice(0, level)));
if (hoverDelay === 0) update();
else timerRef.current = window.setTimeout(update, hoverDelay);
};
pointerenter は、ポインターが要素に入ったときに起きるイベントです。タイマーの中で実行する更新は、setOpenPath に関数を渡す形にしています。関数を渡すと、React は更新用関数として、その時点の state から次の state を作ります。待っている間に openPath が変わっても、最新の値をもとに次の配列を作れます。
検証では、「書き出す」から「テキスト」まで、16ms おきに12回に分けてポインターを動かしました(約0.2秒)。待ち時間を 0ms にした場合と 200ms にした場合の結果は次のとおりです。
hoverDelay=0: 通った項目 [共有 → リンクをコピー → メールで送る] / 最後の位置にある項目: メールで送る / 0.3秒後: 開いているメニュー: [メニュー, 共有] / フォーカス: メールで送る / 選んだ項目:(まだ選んでいません)
hoverDelay=200: 通った項目 [共有 → 画像 → テキスト] / 最後の位置にある項目: テキスト / 0.3秒後: 開いているメニュー: [メニュー, 書き出す] / フォーカス: テキスト / 選んだ項目:(まだ選んでいません)
待たない場合は、「共有」の上を通った時点で「共有」のサブメニューに切り替わり、ポインターが着いた位置には「メールで送る」がありました。200ms 待つ場合は、途中で「共有」と「画像」の上を通っても「書き出す」のサブメニューが開いたままで、「テキスト」に着いています。
待つ分、ふつうに親の項目へポインターを乗せたときも、サブメニューが開くのは少し後になります。検証では、乗せてから 100ms の時点ではまだ開いておらず、250ms の時点で開いていました。
書き出すに乗って100ms 開いているメニュー: [メニュー] / フォーカス: 書き出す / 選んだ項目:(まだ選んでいません)
書き出すに乗って250ms 開いているメニュー: [メニュー, 書き出す] / フォーカス: 書き出す / 選んだ項目:(まだ選んでいません)
待たずに開きたい人のために、親の項目を押したときはタイマーを使わずにすぐ開くようにしています(renderMenu の onClick)。乗せてすぐに「共有」を押したところ、押した直後にサブメニューが開いていました。
共有を押した直後 開いているメニュー: [メニュー, 共有] / フォーカス: 共有 / 選んだ項目:書き出す > 画像 > PNG
キーボードでは、→ で開いて ← と Esc で1つ上に戻る
キー操作は、先ほどのメニューのパターンにある、メニューを開いたあとのキーボード操作に合わせました。
| キー | 動き |
|---|---|
| ↓ と ↑ | 同じ階層の中で、次と前の項目に移る(端まで行ったら反対側に戻る) |
| → | 親の項目なら、サブメニューを開いて最初の項目に移る |
| Enter、Space | 親の項目ならサブメニューを開く。それ以外は項目を選んでメニューを閉じる |
| ← | サブメニューを閉じて、親の項目に戻る |
| Esc | 今いるメニューを閉じて、開いた元(親の項目かボタン)に戻る |
| Tab | メニューをすべて閉じ、次の要素へ移る |
// level 階層目の項目 item でキーが押されたとき。parents は、上の階層の親の項目
const handleKeyDown = (e: KeyboardEvent<HTMLElement>, level: number, item: MenuItem, parents: MenuItem[]) => {
const el = e.currentTarget;
const siblings = [...el.closest('[role="menu"]')!.querySelectorAll<HTMLElement>(':scope > li > [role="menuitem"]')];
const index = siblings.indexOf(el);
const parent = parents.at(-1);
switch (e.key) {
case 'ArrowDown':
siblings[(index + 1) % siblings.length].focus();
break;
case 'ArrowUp':
siblings[(index - 1 + siblings.length) % siblings.length].focus();
break;
case 'Enter':
case ' ':
case 'ArrowRight':
if (!item.children) {
if (e.key === 'ArrowRight') return;
select([...parents, item]);
break;
}
// サブメニューを開き、その最初の項目にフォーカスを移す
cancelTimer();
flushSync(() => setOpenPath([...openPath.slice(0, level), item.id]));
focusFirstIn(menuId(item.id));
break;
case 'ArrowLeft':
case 'Escape':
cancelTimer();
if (!parent) {
if (e.key === 'Escape') closeAll(true);
return;
}
// 1つ上の階層へ戻り、開いていたサブメニューの親にフォーカスを移す
setOpenPath(openPath.slice(0, level - 1));
document.getElementById(itemId(parent.id))?.focus();
break;
case 'Tab':
closeAll(false);
return; // 既定の動き(次の要素へ移る)は止めない
default:
return;
}
e.preventDefault();
};
→ でサブメニューを開いたときは、その最初の項目にフォーカスを移します。ところが、サブメニューの ul は、state を変えて React が描き直すまで DOM にありません。そこで、flushSync の中で setOpenPath を呼び、DOM を同期的に更新させてから focusFirstIn で探しています。公式リファレンスでは、flushSync は最後の手段として使うように書かれています。ここでは、→、Enter、Space でサブメニューを開くときと、ボタンから一番上のメニューを開くときだけに使っています。
Tab で preventDefault を呼ばずに return しているのは、メニューを閉じたうえで、フォーカスはブラウザの既定の動きどおり次の要素へ移したいからです。
ボタンにフォーカスがある状態から ↓ で開いた結果です。最初の ↑ で末尾の「削除」へ回り込み、「書き出す」「画像」と進んで3段目を開いたあと、← と Esc で戻っています。
ArrowDown 開いているメニュー: [メニュー] / フォーカス: 新規作成 / 選んだ項目:書き出す > 画像 > PNG
ArrowUp 開いているメニュー: [メニュー] / フォーカス: 削除 / 選んだ項目:書き出す > 画像 > PNG
ArrowDown 開いているメニュー: [メニュー] / フォーカス: 新規作成 / 選んだ項目:書き出す > 画像 > PNG
ArrowDown 開いているメニュー: [メニュー] / フォーカス: 開く / 選んだ項目:書き出す > 画像 > PNG
ArrowDown 開いているメニュー: [メニュー] / フォーカス: 書き出す / 選んだ項目:書き出す > 画像 > PNG
ArrowRight 開いているメニュー: [メニュー, 書き出す] / フォーカス: PDF / 選んだ項目:書き出す > 画像 > PNG
ArrowDown 開いているメニュー: [メニュー, 書き出す] / フォーカス: 画像 / 選んだ項目:書き出す > 画像 > PNG
ArrowRight 開いているメニュー: [メニュー, 書き出す, 画像] / フォーカス: PNG / 選んだ項目:書き出す > 画像 > PNG
ArrowLeft 開いているメニュー: [メニュー, 書き出す] / フォーカス: 画像 / 選んだ項目:書き出す > 画像 > PNG
Escape 開いているメニュー: [メニュー] / フォーカス: 書き出す / 選んだ項目:書き出す > 画像 > PNG
Escape 開いているメニュー: [] / フォーカス: メニュー / 選んだ項目:書き出す > 画像 > PNG
← と Esc で1段ずつ閉じていき、一番上のメニューで Esc を押すと、すべて閉じてボタンにフォーカスが戻りました。末端の項目(「リンクをコピー」)で Space を押したときも、Enter と同じように項目が選ばれてメニューが閉じ、フォーカスがボタンに戻りました。
Space 開いているメニュー: [] / フォーカス: メニュー / 選んだ項目:共有 > リンクをコピー
Tab を押したときは、メニューがすべて閉じて、フォーカスは次のボタンに移りました。
Tab 開いているメニュー: [] / フォーカス: メニューの次のボタン / 選んだ項目:共有 > リンクをコピー
ボタンには aria-haspopup と aria-expanded を付ける
メニューを開くボタンは、WAI-ARIA のメニューボタンのパターンに合わせて、aria-haspopup="menu" を付け、開いているかどうかを aria-expanded で表します。このパターンでは、Enter と Space で開いて最初の項目に移ることが求められ、↓ で開くのは任意です。Enter と Space はボタンのクリックとして onClick に届くので、onKeyDown では ↓ だけを受け取っています。
return (
<div className="dropdown" ref={rootRef}>
<button
ref={buttonRef}
id={`${baseId}-button`}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId('root') : undefined}
onClick={() => (open ? closeAll(false) : openRoot())}
onKeyDown={(e) => {
if (!open && e.key === 'ArrowDown') {
e.preventDefault();
openRoot();
}
}}
>
{label}
</button>
{open && renderMenu(items, 0, [])}
</div>
);
メニューの外を押したときは、document の pointerdown で受け取って、すべて閉じます。押された要素が、ボタンとメニューを包む div の中にあるかどうかを contains で調べています。
// メニューの外を押したら、全部閉じる
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) closeAll(false);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [open]);
項目を選んだときと Esc で閉じたときは、フォーカスをボタンに戻します。外を押したときは、利用者がほかの場所を操作しようとしているので、フォーカスを戻しません。検証でも、PNG を押したあとはフォーカスがボタン(「メニュー」)にあり、外を押したあとはどこにもありませんでした。
PNG を押した 開いているメニュー: [] / フォーカス: メニュー / 選んだ項目:書き出す > 画像 > PNG
外を押した 開いているメニュー: [] / フォーカス: body(どこにもない) / 選んだ項目:書き出す > 画像 > PNG
この違いは、閉じる処理をまとめた closeAll の引数 returnFocus で分けています。項目を選んだときと Esc では true、外を押したときと Tab では false を渡します。
const closeAll = (returnFocus: boolean) => {
cancelTimer();
setOpen(false);
setOpenPath([]);
if (returnFocus) buttonRef.current?.focus();
};





