Tips & Tricks (更新: 2026/6/2)

Claude Codeでモーダルダイアログを安全に実装する実践ガイド

Claude Codeでdialog要素、React実装、失敗例、アクセシビリティ検証まで押さえる実践ガイド。

Claude Codeでモーダルダイアログを安全に実装する実践ガイド

モーダルダイアログは、画面の上に小さな別画面を重ね、ユーザーに一時的な判断や入力を求めるUIです。重要なのは「目立つ箱」ではなく、開いている間は背後の画面を触らせず、キーボードのフォーカスを中に入れ、閉じたら元のボタンへ戻すことです。

Claude Codeに「いい感じのモーダルを作って」と頼むと、見た目は整っていても、Escapeで閉じない、Tabで背後へ抜ける、スクリーンリーダーがタイトルを読まない、スマホで下のボタンが切れる、という失敗が起きます。この記事では初心者にも分かるように、モーダルの考え方、Claude Codeへの依頼文、コピペで動く例、落とし穴、検証方法までまとめます。

一次情報として、ブラウザ標準の <dialog> 要素はMDN、期待されるキーボード操作は WAI-ARIA APG Dialog Modal Pattern、フォーカスの順序と見え方は WCAG 2.2 Focus OrderFocus Visible を確認します。関連する基礎は Claude Codeアクセシビリティ実装、ライブラリを使う場合は Claude CodeとRadix UI、検索型UIは コマンドパレット実装、邪魔しない通知は トースト通知 も合わせて読むと判断しやすくなります。

モーダルを使う前に決めること

モーダルは便利ですが、使いすぎると離脱を増やします。モーダルに向くのは、削除の確認、短い設定変更、支払い前の最終確認、ログイン要求、コマンドパレットのように、今の画面文脈を保ったまま短く終わる操作です。

逆に、長いフォーム、規約全文、複数ページにまたがる作業、頻繁に表示する広告、ユーザーが後で見ればよいお知らせはモーダルに向きません。背後の画面を止めるほど重要か、閉じた後にどこへ戻すか、モバイル幅で最後まで読めるかを先に決めます。

専門用語も最初に言い換えておきます。フォーカスは「キーボード操作の現在地」です。フォーカストラップは「Tab移動をダイアログ内に閉じ込めること」です。inertは「背後の画面を操作対象から外す状態」です。ARIAは「支援技術にUIの意味を伝える属性」です。Claude Codeに依頼するときは、この言い換えを添えるだけでも出力が安定します。

flowchart TD
  A["ユーザーがボタンを押す"] --> B["dialog.showModal()で開く"]
  B --> C["フォーカスをタイトルか最初の操作へ移す"]
  C --> D["Tab、Shift+Tab、Escapeを確認する"]
  D --> E["確定、キャンセル、外側クリックを分ける"]
  E --> F["閉じたら呼び出し元へフォーカスを戻す"]

Claude Codeに渡す実装ブリーフ

Claude Codeには、見た目より先に動作条件を渡します。次の文をそのまま貼り、対象ファイル名だけ変えるとレビューしやすい差分になります。

React + TypeScriptの既存画面にモーダルダイアログを追加してください。

要件:
- まず既存のボタン、フォーム、CSS、テストを読んでから提案する
- HTMLのdialog要素を優先し、使えない理由があれば説明する
- 開いたらタイトルまたは最初の操作にフォーカスを移す
- Escape、キャンセル、確定、外側クリックを別々に扱う
- 閉じたら開いたボタンへフォーカスを戻す
- aria-labelledbyと必要ならaria-describedbyを付ける
- outlineを消さず、:focus-visibleで見える状態にする
- 320px幅でも本文とボタンが切れないCSSにする
- 失敗例と手動確認手順をPR本文に残す

触ってよいファイル:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts

コピペで動くHTMLの最小例

まずはフレームワークなしで挙動を理解します。次を modal-demo.html として保存してブラウザで開くと、showModal()close()cancelイベント、外側クリック、フォーカスの戻りを確認できます。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Dialog demo</title>
    <style>
      body {
        font-family: system-ui, sans-serif;
        line-height: 1.7;
        padding: 2rem;
      }

      button {
        font: inherit;
        border: 0;
        border-radius: 6px;
        padding: 0.7rem 1rem;
        cursor: pointer;
      }

      .primary {
        background: #2563eb;
        color: white;
      }

      .danger {
        background: #dc2626;
        color: white;
      }

      dialog {
        width: min(calc(100vw - 2rem), 28rem);
        border: 0;
        border-radius: 8px;
        padding: 0;
        box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
      }

      dialog::backdrop {
        background: rgb(15 23 42 / 0.58);
      }

      .modal-body {
        padding: 1.25rem;
      }

      .button-row {
        display: flex;
        flex-wrap: wrap;
        justify-content: flex-end;
        gap: 0.75rem;
        margin-top: 1.5rem;
      }

      :focus-visible {
        outline: 3px solid #f59e0b;
        outline-offset: 3px;
      }
    </style>
  </head>
  <body>
    <main>
      <h1>Project settings</h1>
      <p>削除のように取り消しにくい操作だけ、モーダルで止めます。</p>
      <button id="open-dialog" class="danger" type="button">
        プロジェクトを削除
      </button>
    </main>

    <dialog id="confirm-dialog" aria-labelledby="dialog-title">
      <div class="modal-body">
        <h2 id="dialog-title" tabindex="-1">本当に削除しますか?</h2>
        <p>この操作は取り消せません。必要なら先にデータをエクスポートしてください。</p>
        <div class="button-row">
          <button id="cancel-dialog" type="button">キャンセル</button>
          <button id="confirm-delete" class="danger" type="button">
            削除する
          </button>
        </div>
      </div>
    </dialog>

    <script>
      const openButton = document.querySelector("#open-dialog");
      const dialog = document.querySelector("#confirm-dialog");
      const title = document.querySelector("#dialog-title");
      const cancelButton = document.querySelector("#cancel-dialog");
      const confirmButton = document.querySelector("#confirm-delete");

      openButton.addEventListener("click", () => {
        dialog.showModal();
        title.focus();
      });

      cancelButton.addEventListener("click", () => dialog.close("cancel"));

      confirmButton.addEventListener("click", () => {
        console.log("delete project");
        dialog.close("confirm");
      });

      dialog.addEventListener("click", (event) => {
        if (event.target === dialog) {
          dialog.close("backdrop");
        }
      });

      dialog.addEventListener("close", () => {
        openButton.focus();
        console.log(`closed by: ${dialog.returnValue || "unknown"}`);
      });
    </script>
  </body>
</html>

MDNの説明どおり、モーダルとして開くときは open 属性を直接付けるのではなく showModal() を使います。open だけを付けると、背後を操作できてしまう状態になりやすく、モーダルとしての意味が崩れます。

Reactで使う共通コンポーネント

実務では、削除確認、プラン変更、招待フォームなどで同じ土台を使い回します。次の例はVite、React SPA、Next.jsのClient Componentで使える形です。Next.js App Routerならファイル先頭に "use client"; を追加してください。

import * as React from "react";
import "./modal-dialog.css";

type ModalDialogProps = {
  open: boolean;
  title: string;
  description?: string;
  closeOnBackdrop?: boolean;
  onClose: () => void;
  children: React.ReactNode;
  footer: React.ReactNode;
};

const focusableSelector = [
  "a[href]",
  "button:not([disabled])",
  "input:not([disabled])",
  "select:not([disabled])",
  "textarea:not([disabled])",
  "[tabindex]:not([tabindex='-1'])",
].join(",");

export function ModalDialog({
  open,
  title,
  description,
  closeOnBackdrop = true,
  onClose,
  children,
  footer,
}: ModalDialogProps) {
  const dialogRef = React.useRef<HTMLDialogElement>(null);
  const titleRef = React.useRef<HTMLHeadingElement>(null);
  const openerRef = React.useRef<HTMLElement | null>(null);
  const titleId = React.useId();
  const descriptionId = React.useId();

  React.useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (open && !dialog.open) {
      openerRef.current =
        document.activeElement instanceof HTMLElement
          ? document.activeElement
          : null;
      dialog.showModal();

      window.requestAnimationFrame(() => {
        const preferred = dialog.querySelector<HTMLElement>("[data-autofocus]");
        const firstFocusable = dialog.querySelector<HTMLElement>(
          focusableSelector,
        );
        (preferred ?? firstFocusable ?? titleRef.current)?.focus();
      });
    }

    if (!open && dialog.open) {
      dialog.close();
    }
  }, [open]);

  React.useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    function handleClose() {
      onClose();
      openerRef.current?.focus();
    }

    function handleClick(event: MouseEvent) {
      if (event.target === dialog && closeOnBackdrop) {
        onClose();
      }
    }

    dialog.addEventListener("close", handleClose);
    dialog.addEventListener("click", handleClick);

    return () => {
      dialog.removeEventListener("close", handleClose);
      dialog.removeEventListener("click", handleClick);
    };
  }, [closeOnBackdrop, onClose]);

  return (
    <dialog
      ref={dialogRef}
      className="app-modal"
      aria-labelledby={titleId}
      aria-describedby={description ? descriptionId : undefined}
    >
      <div className="app-modal__body">
        <div className="app-modal__header">
          <h2 id={titleId} ref={titleRef} tabIndex={-1}>
            {title}
          </h2>
          <button
            type="button"
            className="app-modal__icon"
            aria-label="ダイアログを閉じる"
            onClick={onClose}
          >
            x
          </button>
        </div>

        {description ? (
          <p id={descriptionId} className="app-modal__description">
            {description}
          </p>
        ) : null}

        <div className="app-modal__content">{children}</div>
        <div className="app-modal__footer">{footer}</div>
      </div>
    </dialog>
  );
}
.app-modal {
  width: min(calc(100vw - 32px), 520px);
  max-height: calc(100vh - 32px);
  border: 0;
  border-radius: 8px;
  padding: 0;
  color: #0f172a;
  box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
}

.app-modal::backdrop {
  background: rgb(15 23 42 / 0.58);
}

.app-modal__body {
  display: grid;
  gap: 16px;
  padding: 24px;
}

.app-modal__header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
}

.app-modal__header h2 {
  margin: 0;
  font-size: 1.25rem;
  line-height: 1.3;
}

.app-modal__description {
  margin: 0;
  color: #475569;
}

.app-modal__footer {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 12px;
}

.app-modal__icon {
  width: 36px;
  height: 36px;
  border: 0;
  border-radius: 999px;
  background: #e2e8f0;
  cursor: pointer;
}

:focus-visible {
  outline: 3px solid #f59e0b;
  outline-offset: 3px;
}

@media (max-width: 480px) {
  .app-modal__footer {
    flex-direction: column-reverse;
  }

  .app-modal__footer button {
    width: 100%;
  }
}

3つの現実的なユースケース

ユースケースモーダルにする理由Claude Codeに追加で頼むこと
削除、解約、権限変更の確認取り消しにくい操作なので一度止める危険ボタンの文言、二重送信防止、監査ログ
招待、請求先、短い設定フォーム元画面の文脈を保ったまま完了できる入力エラー、送信中表示、成功後のフォーカス
コマンドパレットや検索画面移動せずに素早く操作できる矢印キー、aria-activedescendant、空状態

削除確認なら、外側クリックで即キャンセルしてよいかを決めます。重要操作では、外側クリックで閉じない設計のほうが安全なこともあります。

短いフォームでは、送信失敗時にモーダルを閉じないことが大切です。エラーを読み上げられるようにし、成功時だけ閉じます。フォーム全般は Claude Codeフォームバリデーション と合わせると実装漏れを減らせます。

コマンドパレットは、見た目はモーダルでも中身は検索UIです。候補リストの選択、空状態、重い検索の遅延、危険コマンドの確認を分けます。作り込みが増える場合は コマンドパレット実装 のように専用記事へ切り出したほうが安全です。

Promiseで使う確認ダイアログ

管理画面では「確認してから処理する」を何度も書きます。次の例は、上の ModalDialog を使って await confirmDialog(...) と書けるようにするヘルパーです。

import * as React from "react";
import { createRoot } from "react-dom/client";
import { ModalDialog } from "./ModalDialog";

type ConfirmDialogOptions = {
  title: string;
  message: string;
  confirmLabel?: string;
  cancelLabel?: string;
  danger?: boolean;
};

export function confirmDialog(
  options: ConfirmDialogOptions,
): Promise<boolean> {
  return new Promise((resolve) => {
    const container = document.createElement("div");
    document.body.appendChild(container);
    const root = createRoot(container);

    function finish(result: boolean) {
      root.unmount();
      container.remove();
      resolve(result);
    }

    function ConfirmHost() {
      return (
        <ModalDialog
          open
          title={options.title}
          description={options.message}
          closeOnBackdrop={false}
          onClose={() => finish(false)}
          footer={
            <>
              <button type="button" onClick={() => finish(false)}>
                {options.cancelLabel ?? "キャンセル"}
              </button>
              <button
                type="button"
                data-autofocus
                className={options.danger ? "danger" : "primary"}
                onClick={() => finish(true)}
              >
                {options.confirmLabel ?? "確認"}
              </button>
            </>
          }
        >
          <p>続行する前に内容を確認してください。</p>
        </ModalDialog>
      );
    }

    root.render(<ConfirmHost />);
  });
}

async function handleDeleteProject(projectId: string) {
  const ok = await confirmDialog({
    title: "プロジェクトを削除しますか?",
    message: "この操作は取り消せません。先にエクスポートしてください。",
    confirmLabel: "削除する",
    danger: true,
  });

  if (!ok) return;
  await fetch(`/api/projects/${projectId}`, { method: "DELETE" });
}

落とし穴とアクセシビリティ確認

一番多い失敗は、閉じるボタンがマウス専用になることです。必ず button を使い、aria-label を付け、EnterSpace で動くことを見ます。

二つ目は、タイトルを見た目の都合で消すことです。画面上に見せない場合でも、スクリーンリーダー用の名前は必要です。aria-labelledby でタイトルへ結び、説明文があるときだけ aria-describedby を付けます。長い本文全体を説明に結ぶと、開いた瞬間に読み上げが長くなりすぎます。

三つ目は、フォーカスリングをCSSで消すことです。outline: none だけを書くと、キーボード利用者は現在地を失います。消すのではなく、:focus-visible でデザインに合うリングを出します。

四つ目は、モーダルの中から別のモーダルを開くことです。多段モーダルはフォーカスの戻り先、Escapeの意味、確認の責任が複雑になります。削除確認の上にさらに確認を重ねるより、文言を明確にし、取り消し導線やUndoを検討してください。

五つ目は、モバイルの高さ不足です。本文が長い、キーボードが出る、固定フッターがある、という条件で確定ボタンが画面外に落ちます。max-heightoverflow: auto を入れ、320px幅で最後のボタンまで触れるか確認します。

六つ目は、Claude Codeに「アクセシブルにして」とだけ頼むことです。具体的に、Tabで中を移動できる、Escapeで閉じる、閉じたら元のボタンへ戻る、タイトルが読まれる、エラーが読まれる、スマホで切れない、という検証項目を渡します。

Playwrightで最低限を自動確認する

自動テストだけでは読み上げ品質までは分かりません。それでも、開閉とフォーカスの事故はかなり拾えます。

import { expect, test } from "@playwright/test";

test("modal opens, closes, and returns focus", async ({ page }) => {
  await page.goto("/settings");

  const trigger = page.getByRole("button", { name: "プロジェクトを削除" });
  await trigger.click();

  const dialog = page.getByRole("dialog", {
    name: "プロジェクトを削除しますか?",
  });
  await expect(dialog).toBeVisible();

  await page.keyboard.press("Tab");
  await expect(page.locator(":focus")).toBeVisible();

  await page.keyboard.press("Escape");
  await expect(dialog).toBeHidden();
  await expect(trigger).toBeFocused();
});

手動確認では、マウスを使わずに TabShift+TabEnterSpaceEscape だけで操作します。次に、WindowsならNVDA、macOSならVoiceOverでタイトル、説明、ボタン名を読み上げます。最後にスマホ幅で本文とフッターが切れないかを見ます。

収益化につなげるCTA設計

モーダルは収益導線にも影響します。教材販売の購入確認、問い合わせ前の条件確認、無料チートシートのメール登録、法人研修の相談フォームなど、ユーザーの不安が強い場所で使うからです。ただし、広告のように無理に割り込ませると信頼を落とします。

ClaudeCodeLabでは、Claude Codeの導入、CLAUDE.md整備、アクセシブルなUIレビュー、既存React画面の改善を Claude Code研修・導入相談 で扱っています。個人でまず型を手元に置きたい場合は 商品一覧無料チートシート から始めると、モーダルだけでなくレビュー、テスト、権限設計までまとめて整えやすくなります。

まとめ

Claude Codeでモーダルダイアログを作るときは、きれいな箱を生成させる前に、使うべき場面、フォーカス、閉じ方、読み上げ、モバイル幅、検証方法を決めます。ブラウザ標準の dialog.showModal() を使えるなら土台にし、複雑なUIはRadix UIなど実績のある部品も検討します。

この記事で紹介した内容を実際に試した結果、Masaの検証用React画面では、最初に出した見た目重視のモーダルよりも、フォーカスの戻り先とモバイル幅を先に指定した依頼のほうがレビューが短く済みました。特に「閉じたら開いたボタンへ戻る」「危険操作は外側クリックで閉じない」「320px幅で確定ボタンまで触れる」の3点をClaude Codeへの完了条件に入れると、公開前の手戻りが大きく減りました。

#Claude Code #モーダル #ダイアログ #React #アクセシビリティ
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。