Reactでモーダルを閉じたあと、元のボタンにフォーカスを戻す方法

​​2026年10月24日​React​Playwright,TypeScript,useEffect,アクセシビリティ,カスタムフック,モーダル

<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 キーを押した結果です。

text
開いたあと: {"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 の本体はマウントのとき、クリーンアップはアンマウントのときに動きます。そのため、本体が「開いたとき」、クリーンアップが「閉じたとき」の処理になります。

ts
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 を呼びます。

tsx
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 に渡します。検証に使った画面から抜き出し、検証用の切り替えを除いたものです。

tsx
{editing && (
  <Modal title="タイトルを編集" onClose={() => setEditing(null)} initialFocusRef={inputRef}>
    <input ref={inputRef} aria-label="タイトル" value={draft} onChange={(e) => setDraft(e.target.value)} />
    {/* 「キャンセル」と「保存」のボタン */}
  </Modal>
)}

このモーダルで、最初と同じ操作をした結果です。

text
開いたあと: {"focus":"input「タイトル」","focusVisible":true}
Esc キーを押したあと: {"focus":"button「猫の水飲みを編集」","focusVisible":true}
そのあと Tab キーを1回押したあと: {"focus":"button「猫の水飲みを削除」","focusVisible":true}

閉じると、フォーカスは開いた「編集」ボタンに戻りました。次の Tab キーでは、すぐ隣の「削除」ボタンに進みました。

前半の戻さない版では、Esc キーで閉じるとフォーカスが body になり、Tab キーで先頭のメニューに移る。後半の戻す版では、Esc キーで閉じると猫の水飲みの編集ボタンに戻り、Tab キーで隣の削除ボタンに移る
戻さない版と戻す版で、Enter キー、Esc キー、Tab キーを順に押したときのフォーカスの動き

Esc キー以外の閉じ方でも試しました。キーボードで「キャンセル」を押した場合も、マウスで開いてマウスで「保存」を押した場合も、フォーカスは開いた「編集」ボタンに戻りました。戻す処理はアンマウントのときに動くので、どのボタンで閉じたかは関係ありません。

text
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 を覚えても、元のボタンにはなりません。検証に使った版から、覚えた要素を確かめるために書き出す行を除いたものです。

tsx
// 中の入力欄の 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 キーで閉じたあとのフォーカスです。

text
開いたあと: {"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 キーで「削除する」を押した結果です。

text
Enter キーで「削除する」を押したあと: {"focus":"body","focusVisible":false}
  一覧のタイトル: ["猫の爪とぎ","猫の寝床"]
そのあと Tab キーを1回押したあと: {"focus":"button「メニュー」","focusVisible":true}

行が消えたあとは、戻す先の「削除」ボタンも DOM から外れています。fallbackRef がないと戻し先がないので、フォーカスは body のままでした。useReturnFocus で isConnected を調べているのは、元の要素が消えたことを見分けて、代わりの戻し先を選ぶためです。isConnected は、MDN の isConnected のページにあるとおり、ノードがドキュメントにつながっているかどうかを返します。

代わりの戻し先には、一覧の見出しを選びました。見出しはそのままではフォーカスできないので、tabIndex={-1} を付けて、ref を fallbackRef に渡します。

tsx
<h2 ref={headingRef} tabIndex={-1}>記事の一覧</h2>

{deleting && (
  <Modal
    title={`「${deleting.title}」を削除しますか?`}
    onClose={() => setDeleting(null)}
    initialFocusRef={deleteRef}
    fallbackRef={headingRef}
  >
    {/* 「キャンセル」と「削除する」のボタン */}
  </Modal>
)}
text
Enter キーで「削除する」を押したあと: {"focus":"h2「記事の一覧」","focusVisible":true}
  一覧のタイトル: ["猫の爪とぎ","猫の寝床"]
そのあと Tab キーを1回押したあと: {"focus":"button「猫の爪とぎを編集」","focusVisible":true}

フォーカスは見出しに移り、次の Tab キーで一覧の最初のボタンに進みました。どこを戻し先にするかは、画面の作りしだいです。消えた行の次の行のボタンにする作りもあります。

StrictMode の中でも同じように戻る

開発中の StrictMode では、Effect の本体とクリーンアップが、最初に一度余分に動きます(公式リファレンスの StrictMode のページ)。useReturnFocus では、開いた直後に「元のボタンへ戻す」→「もう一度ボタンを覚えて、中へ移す」が起きるはずです。

<StrictMode> で包んだ画面で試したところ、結果は包まないときと同じでした。

text
開いたあと: {"focus":"input「タイトル」","focusVisible":true}
Esc キーを押したあと: {"focus":"button「猫の水飲みを編集」","focusVisible":true}
  Tab キー 3 回で「猫の寝床を削除」へ
「猫の寝床」を削除したあと: {"focus":"h2「記事の一覧」","focusVisible":true}

余分なクリーンアップでフォーカスがいったん元のボタンに戻るため、2回目の本体でも元のボタンを覚えられたと考えられます。ここまでのどの検証でも、ブラウザのコンソールにエラーや警告は出ませんでした。

フォーカスを戻すときのポイント

自作のモーダルでフォーカスを戻すには、開いているあいだだけモーダルを描画し、Effect の中で次の順に書きます。

  1. document.activeElement を覚える
  2. モーダルの中の要素に focus() を呼ぶ
  3. クリーンアップで、覚えた要素に focus() を呼ぶ

最初のフォーカスを autoFocus で移すと、覚える要素がモーダルの中になってしまうので使いません。開いたボタンが閉じるときに消える画面では、isConnected で確かめて、見出しなどの代わりの要素に移します。公式リファレンスの createPortal のページも、ポータルでモーダルを作るときは、フォーカスの出入りを自然に扱うよう勧めています。

​Posted by 86