Reactでモーダルを閉じたあと、元のボタンにフォーカスを戻す方法
<div> と createPortal で自作したモーダルは、閉じたときにフォーカスをどこにも戻しません。キーボードで操作している人は、閉じるたびにページの先頭からやり直すことになります。モーダルが開くときに、その時点でフォーカスがある要素を覚えておき、閉じたときにその要素へ focus() を呼べば、元のボタンに戻せます。
筆者は検証環境で、記事の一覧に「編集」「削除」のボタンが並ぶ画面を作り、モーダルを開いて閉じたときのフォーカスの移り先を Chromium で確かめました。
検証環境は 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 で変換し、Chromium 141 を Playwright 1.56.1 で操作して確かめました。ほかのブラウザでは試していません。モーダルの中だけで Tab キーのフォーカスを巡回させる処理(フォーカストラップ)は、この記事では扱いません。
戻さないと、フォーカスはページの先頭に飛ぶ
まず、開いたときに中の入力欄へフォーカスを移すだけで、閉じたときには何もしないモーダルで試しました。Tab キーで「猫の水飲み」の行の「編集」ボタンまで進み、Enter キーで開いて、Esc キーで閉じ、もう一度 Tab キーを押した結果です。
開いたあと: {"focus":"input「タイトル」","focusVisible":true}
Esc キーを押したあと: {"focus":"body","focusVisible":false}
モーダルの数: 0
そのあと Tab キーを1回押したあと: {"focus":"button「メニュー」","focusVisible":true}
focus は document.activeElement、focusVisible はその要素が :focus-visible に当てはまるかどうかです。モーダルが消えると、フォーカスは body に落ちました。次の Tab キーで移った先は、ページの最初のボタン「メニュー」でした。元の「編集」ボタンの続きからではありません。
WAI-ARIA のモーダルダイアログのパターンでは、ダイアログを閉じたら、ダイアログを開いた要素にフォーカスを戻すよう書かれています。筆者が別の記事「ReactでHTMLのdialog要素を使い、モーダルを作る方法」で Chromium を使って試したときは、showModal() で開いた <dialog> は、自分で書かなくても、閉じたあとのフォーカスが開いたボタンに戻りました。この記事は、<dialog> を使わずに自作するモーダルが対象です。
開く前の要素を覚えて、閉じたら戻すフック
戻す処理は、モーダルの中で使うフックにまとめました。モーダルは {editing && <Modal />} のように、開いているあいだだけ描画する作りにします。公式リファレンスの StrictMode のページにあるとおり、Effect の本体はマウントのとき、クリーンアップはアンマウントのときに動きます。そのため、本体が「開いたとき」、クリーンアップが「閉じたとき」の処理になります。
import { useEffect, type RefObject } from 'react';
// モーダルが表示されたら中へフォーカスを移し、
// 閉じたら(アンマウントされたら)、開く前にフォーカスがあった要素へ戻す
export function useReturnFocus(
initialFocusRef: RefObject<HTMLElement | null>,
fallbackRef?: RefObject<HTMLElement | null>,
) {
useEffect(() => {
// 中へフォーカスを移す前に、いまフォーカスがある要素を覚えておく
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
initialFocusRef.current?.focus();
return () => {
// 元の要素が画面から消えていたら、代わりの要素へ
if (previous?.isConnected) previous.focus();
else fallbackRef?.current?.focus();
};
}, [initialFocusRef, fallbackRef]);
}
大事なのは、Effect の中の順番です。先に document.activeElement を覚えてから、モーダルの中へフォーカスを移します。先に中へフォーカスが移ってしまうと、うまく戻りません(autoFocus を使った場合として、次の節で試します)。fallbackRef は、元の要素が消えていたときの戻し先です。
モーダルの本体は、createPortal で document.body の下に描画し、Esc キーで onClose を呼びます。
import { useId, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
import { useReturnFocus } from './useReturnFocus';
export type ModalProps = {
title: string;
onClose: () => void;
// 開いたときにフォーカスを移す要素
initialFocusRef: RefObject<HTMLElement | null>;
// 開く前の要素が消えていたときに、フォーカスを移す要素
fallbackRef?: RefObject<HTMLElement | null>;
children: ReactNode;
};
export function Modal({ title, onClose, initialFocusRef, fallbackRef, children }: ModalProps) {
const titleId = useId();
useReturnFocus(initialFocusRef, fallbackRef);
return createPortal(
<div className="backdrop">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="modal"
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
<h3 id={titleId}>{title}</h3>
{children}
</div>
</div>,
document.body,
);
}
使う側では、editing に値が入っているあいだだけ Modal を描画し、中の入力欄の ref を initialFocusRef に渡します。検証に使った画面から抜き出し、検証用の切り替えを除いたものです。
{editing && (
<Modal title="タイトルを編集" onClose={() => setEditing(null)} initialFocusRef={inputRef}>
<input ref={inputRef} aria-label="タイトル" value={draft} onChange={(e) => setDraft(e.target.value)} />
{/* 「キャンセル」と「保存」のボタン */}
</Modal>
)}
このモーダルで、最初と同じ操作をした結果です。
開いたあと: {"focus":"input「タイトル」","focusVisible":true}
Esc キーを押したあと: {"focus":"button「猫の水飲みを編集」","focusVisible":true}
そのあと Tab キーを1回押したあと: {"focus":"button「猫の水飲みを削除」","focusVisible":true}
閉じると、フォーカスは開いた「編集」ボタンに戻りました。次の Tab キーでは、すぐ隣の「削除」ボタンに進みました。

Esc キー以外の閉じ方でも試しました。キーボードで「キャンセル」を押した場合も、マウスで開いてマウスで「保存」を押した場合も、フォーカスは開いた「編集」ボタンに戻りました。戻す処理はアンマウントのときに動くので、どのボタンで閉じたかは関係ありません。
Enter キーで「キャンセル」を押したあと: {"focus":"button「猫の爪とぎを編集」","focusVisible":true}
マウスで「猫の寝床を編集」を押したあと: {"focus":"input「タイトル」","focusVisible":true}
マウスで「保存」を押したあと: {"focus":"button「猫の寝床と毛布を編集」","focusVisible":false}
マウスで操作したときは、戻った「編集」ボタンの focusVisible が false でした。ボタンにはフォーカスが戻っていますが、:focus-visible に付けた枠線は出ません。キーボードで操作したときは true で、枠線が出ました。MDN の :focus-visible のページにあるとおり、枠線を出すかどうかはブラウザが判断します。筆者は検証のページで、フォーカスの枠線を :focus-visible に書いていました。そのため検証環境の Chromium では、マウスで閉じたときに戻った「編集」ボタンには枠線が出ませんでした。
MDN の button 要素のページにある「クリックとフォーカス」の節によると、Safari ではボタンをクリックしてもボタンにフォーカスが移りません。そのため、マウスで開いたときは、覚える要素がボタンにならない場合があります。開くボタンの onClick で e.currentTarget を覚えておき、それを戻し先にする作りも考えられます。ただし、筆者は Safari での動きも、この作りも試していません。
autoFocus で中に移すと、覚える要素がずれる
最初のフォーカスを、入力欄の autoFocus で移したくなるかもしれません。その場合、Effect で document.activeElement を覚えても、元のボタンにはなりません。検証に使った版から、覚えた要素を確かめるために書き出す行を除いたものです。
// 中の入力欄の autoFocus で最初のフォーカスを移し、useEffect で元の要素を覚える版
export function ModalAutoFocusEffect({ title, onClose, children }: ModalProps) {
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
return () => previous?.focus();
}, []);
return <Frame title={title} onClose={onClose}>{children}</Frame>;
}
Frame は、Modal と同じ見た目の部分を描画するだけのコンポーネントです。入力欄には autoFocus を付けました。開いたときに覚えた要素と、Esc キーで閉じたあとのフォーカスです。
開いたあと: {"focus":"input「タイトル」","focusVisible":true}
覚えた要素: input「タイトル」
Esc キーを押したあと: {"focus":"body","focusVisible":false}
覚えたのは、モーダルの中の入力欄でした。公式リファレンスの input のページによると、autoFocus が true の要素には、React がマウントのときにフォーカスを移します。Effect が動いた時点で、フォーカスはもう入力欄にあったことになります。閉じたときには、その入力欄はもう画面にないので、フォーカスは body に落ちました。useEffect を useLayoutEffect に替えた版でも、覚えた要素は入力欄で、結果は同じでした。
useReturnFocus では autoFocus を使わず、覚えたあとに自分で focus() を呼ぶことで、この順番を守っています。
元のボタンが消えたら、代わりの戻し先へ
削除を確認するモーダルでは、「削除する」を押すと、その行ごと「削除」ボタンが消えます。WAI-ARIA のパターンでも、開いた要素がもうないときは、作業の流れに合う別の要素にフォーカスを移すよう書かれています。
fallbackRef を渡さずに、「猫の水飲み」の行の「削除」から開いて、Enter キーで「削除する」を押した結果です。
Enter キーで「削除する」を押したあと: {"focus":"body","focusVisible":false}
一覧のタイトル: ["猫の爪とぎ","猫の寝床"]
そのあと Tab キーを1回押したあと: {"focus":"button「メニュー」","focusVisible":true}
行が消えたあとは、戻す先の「削除」ボタンも DOM から外れています。fallbackRef がないと戻し先がないので、フォーカスは body のままでした。useReturnFocus で isConnected を調べているのは、元の要素が消えたことを見分けて、代わりの戻し先を選ぶためです。isConnected は、MDN の isConnected のページにあるとおり、ノードがドキュメントにつながっているかどうかを返します。
代わりの戻し先には、一覧の見出しを選びました。見出しはそのままではフォーカスできないので、tabIndex={-1} を付けて、ref を fallbackRef に渡します。
<h2 ref={headingRef} tabIndex={-1}>記事の一覧</h2>
{deleting && (
<Modal
title={`「${deleting.title}」を削除しますか?`}
onClose={() => setDeleting(null)}
initialFocusRef={deleteRef}
fallbackRef={headingRef}
>
{/* 「キャンセル」と「削除する」のボタン */}
</Modal>
)}
Enter キーで「削除する」を押したあと: {"focus":"h2「記事の一覧」","focusVisible":true}
一覧のタイトル: ["猫の爪とぎ","猫の寝床"]
そのあと Tab キーを1回押したあと: {"focus":"button「猫の爪とぎを編集」","focusVisible":true}
フォーカスは見出しに移り、次の Tab キーで一覧の最初のボタンに進みました。どこを戻し先にするかは、画面の作りしだいです。消えた行の次の行のボタンにする作りもあります。
StrictMode の中でも同じように戻る
開発中の StrictMode では、Effect の本体とクリーンアップが、最初に一度余分に動きます(公式リファレンスの StrictMode のページ)。useReturnFocus では、開いた直後に「元のボタンへ戻す」→「もう一度ボタンを覚えて、中へ移す」が起きるはずです。
<StrictMode> で包んだ画面で試したところ、結果は包まないときと同じでした。
開いたあと: {"focus":"input「タイトル」","focusVisible":true}
Esc キーを押したあと: {"focus":"button「猫の水飲みを編集」","focusVisible":true}
Tab キー 3 回で「猫の寝床を削除」へ
「猫の寝床」を削除したあと: {"focus":"h2「記事の一覧」","focusVisible":true}
余分なクリーンアップでフォーカスがいったん元のボタンに戻るため、2回目の本体でも元のボタンを覚えられたと考えられます。ここまでのどの検証でも、ブラウザのコンソールにエラーや警告は出ませんでした。
フォーカスを戻すときのポイント
自作のモーダルでフォーカスを戻すには、開いているあいだだけモーダルを描画し、Effect の中で次の順に書きます。
document.activeElementを覚える- モーダルの中の要素に
focus()を呼ぶ - クリーンアップで、覚えた要素に
focus()を呼ぶ
最初のフォーカスを autoFocus で移すと、覚える要素がモーダルの中になってしまうので使いません。開いたボタンが閉じるときに消える画面では、isConnected で確かめて、見出しなどの代わりの要素に移します。公式リファレンスの createPortal のページも、ポータルでモーダルを作るときは、フォーカスの出入りを自然に扱うよう勧めています。





