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

用 Claude Code 安全实现模态对话框:React、dialog 与无障碍

用 Claude Code 实现模态对话框,覆盖 dialog 元素、React 代码、焦点管理、常见失败和测试。

用 Claude Code 安全实现模态对话框:React、dialog 与无障碍

模态对话框是在当前页面上方临时打开的小窗口,用来让用户完成一个短任务或做一个明确决定。它不只是一个居中的漂亮盒子。真正重要的是:打开后背景页面不能继续操作,键盘焦点要进入对话框,关闭后焦点要回到打开它的按钮。

如果只让 Claude Code “做一个好看的 modal”,它很容易生成看起来没问题但实际不可用的实现:Escape 不能关闭,Tab 跑到背景页面,屏幕阅读器读不到标题,或者手机上底部按钮被截断。本文把需求拆成可执行的实现简报、可复制运行的代码、真实用例、失败清单和检查方法。

评审时请对照官方资料:浏览器原生能力看 MDN <dialog> 元素,键盘行为看 WAI-ARIA APG Modal Dialog Pattern,焦点顺序和焦点可见性看 WCAG Focus OrderFocus Visible。站内相关主题还有 Claude Code 无障碍实现Radix UI 实践命令面板Toast 通知

先判断是否应该使用模态框

适合模态框的场景通常很短,而且需要暂停当前页面:删除确认、取消订阅、修改权限、邀请成员、结账前登录、命令面板。用户完成后应该能马上回到原页面。

不适合的场景包括长表单、完整协议、多步骤流程、强行弹出的广告,以及用户稍后再看也没有损失的信息。动手前先回答四个问题:这个操作是否重要到需要打断页面?打开后第一个焦点在哪里?哪些操作会关闭?320px 宽的手机上是否能看到并点击最后一个按钮?

几个术语先用白话说明。焦点是“键盘现在所在的位置”。焦点陷阱是“让 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 的实现简报

先写行为约束,再谈样式。下面这段可以直接粘贴,只需要替换文件路径。

请在现有 React + TypeScript 页面中添加模态对话框。

要求:
- 先阅读现有按钮、表单、CSS 和测试,再修改代码。
- 优先使用 HTML dialog 元素;如果不适合,请说明原因。
- 打开后将焦点移到标题或第一个有意义的操作。
- 分别处理 Escape、取消、确认和背景点击。
- 关闭后将焦点还给打开它的按钮。
- 使用 aria-labelledby;有简短说明时使用 aria-describedby。
- 不要移除 outline;使用 :focus-visible 显示焦点。
- 320px 宽度下内容和底部按钮不能被截断。
- 在交付说明中列出失败例和手动检查步骤。

允许修改:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts

可直接运行的 HTML 基线

保存为 modal-demo.html 后用浏览器打开。这个例子不依赖框架,可以看到 showModal()close()、背景点击和焦点返回。

<!doctype html>
<html lang="zh">
  <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;
      }

      .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>项目设置</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>

作为模态框打开时,应调用 showModal(),不要只手动添加 open 属性。只加 open 容易让背景仍可交互,语义就变了。

React 复用组件

实际项目里,删除确认、账单修改、邀请成员和短表单最好共用一个经过评审的底座。下面的组件可用于 Vite、React SPA 或 Next.js Client Component。

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__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%;
  }
}

三个真实用例

用例为什么适合模态框让 Claude Code 额外处理
删除、取消套餐、修改角色操作难以撤销,需要暂停确认危险文案、防重复提交、审计日志
邀请、账单、短设置表单不离开当前上下文即可完成校验、提交中状态、成功后的焦点
命令面板或快速搜索不跳转页面就能执行操作方向键、aria-activedescendant、空状态

短表单失败时不要直接关闭模态框,要在框内显示并朗读错误。命令面板则需要把“对话框外壳”和“列表选择行为”分开审查。

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 />);
  });
}

常见失败与检查

第一,关闭控件只能用鼠标。请使用真正的 button,图标按钮要有可访问名称,并检查 EnterSpace

第二,为了视觉简洁删除标题。即使标题不显示,对话框也需要可访问名称。用 aria-labelledby 连接标题,说明文字简短时再用 aria-describedby

第三,CSS 里写 outline: none 却没有替代方案。键盘用户会失去当前位置。请使用 :focus-visible

第四,层层叠加模态框。嵌套会让焦点返回、Escape 含义和用户责任变得混乱。优先使用更清楚的文案、撤销功能或单个确认步骤。

第五,移动端高度不足。用 max-heightoverflow: auto,并在 320px 宽度下确认底部按钮可见可点。

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。再用 NVDA 或 VoiceOver 检查标题、说明和按钮名称,最后看窄屏手机布局。

CTA 与收益化

模态框经常靠近收益路径:购买确认、咨询表单、邮件订阅、培训申请。越靠近收入,越要克制。不要用广告式弹窗打断读者;有时内联区块、toast 或普通页面更可信。

如果团队需要把 Claude Code、CLAUDE.md、无障碍 UI 审查和 React 改造流程一起整理,可以使用 Claude Code 培训与咨询。个人开发者可以从 产品列表免费速查表 开始,先把提示词、评审和测试固定下来。

实测结果

Masa 在一个小型 React 设置页上验证这个模式时,最有效的不是更复杂的动画,而是把“关闭后焦点回到触发按钮”“危险操作不因背景点击关闭”“320px 宽度下底部按钮可点”写进 Claude Code 的完成条件。这样生成的差分更容易审查,发布前返工明显减少。

#Claude Code #模态框 #对话框 #React #无障碍
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。