Tips & Tricks (Updated: 6/2/2026)

Build Accessible Modal Dialogs with Claude Code: React, dialog, a11y

Use Claude Code to build modal dialogs with dialog, React, focus handling, pitfalls, tests, and a11y checks.

Build Accessible Modal Dialogs with Claude Code: React, dialog, a11y

A modal dialog is a temporary layer that asks the user to make a decision or enter a small amount of information before returning to the page. The hard part is not drawing a centered box. The hard part is making the background inactive, moving focus into the dialog, keeping keyboard users oriented, and returning focus to the button that opened it.

If you ask Claude Code to “make a nice modal”, it can produce a good-looking component that fails in production: Escape does not close it, Tab moves behind it, the title is not announced, or the footer disappears on a small phone. This guide turns that vague request into a practical implementation brief, runnable examples, review checks, and monetization-aware CTA guidance.

Use official references while reviewing. The browser primitive is the MDN <dialog> element, expected keyboard behavior is covered by the WAI-ARIA APG modal dialog pattern, and focus quality is grounded in WCAG 2.2 Focus Order plus Focus Visible. For related ClaudeCodeLab material, read accessibility implementation, Radix UI with Claude Code, command palette implementation, and toast notifications.

Decide Before Building

Modals are useful when the current page context should stay visible but the user must complete a short task. Good cases include destructive confirmation, changing a billing setting, inviting a teammate, signing in before checkout, and opening a command palette.

They are poor fits for long forms, full terms of service text, multi-page workflows, aggressive ads, and information the user can safely read later. Before asking Claude Code to write code, decide whether the task is important enough to stop the page, where focus should go first, what closes the dialog, and how it behaves at a 320px width.

Plain-language definitions help. Focus is the current keyboard location. A focus trap keeps Tab movement inside the dialog. inert means the background is no longer interactive. ARIA is metadata that tells assistive technology what the UI means. Give Claude Code these definitions and the output becomes easier to review.

flowchart TD
  A["User activates trigger"] --> B["Open with dialog.showModal()"]
  B --> C["Move focus to title or first action"]
  C --> D["Check Tab, Shift+Tab, and Escape"]
  D --> E["Separate confirm, cancel, and backdrop behavior"]
  E --> F["Return focus to the trigger"]

Prompt Claude Code with Constraints

Start with behavior, not styling. This brief is ready to paste; only replace the file paths.

Add a modal dialog to the existing React + TypeScript screen.

Requirements:
- Inspect the current buttons, forms, CSS, and tests before editing.
- Prefer the HTML dialog element. Explain if it is not suitable.
- When opened, move focus to the title or the first meaningful action.
- Handle Escape, cancel, confirm, and backdrop clicks separately.
- When closed, return focus to the button that opened the dialog.
- Use aria-labelledby and aria-describedby when a description exists.
- Do not remove outlines. Use :focus-visible for visible focus.
- Keep content and footer buttons usable at 320px width.
- Add failure cases and manual verification notes to the handoff.

Allowed files:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts

Runnable HTML Baseline

Save this as modal-demo.html and open it in a browser. It demonstrates showModal(), close(), cancel, backdrop clicks, and focus return without any framework.

<!doctype html>
<html lang="en">
  <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>Project settings</h1>
      <p>Use a modal only for actions that should pause the page.</p>
      <button id="open-dialog" class="danger" type="button">
        Delete project
      </button>
    </main>

    <dialog id="confirm-dialog" aria-labelledby="dialog-title">
      <div class="modal-body">
        <h2 id="dialog-title" tabindex="-1">Delete this project?</h2>
        <p>This action cannot be undone. Export your data first if needed.</p>
        <div class="button-row">
          <button id="cancel-dialog" type="button">Cancel</button>
          <button id="confirm-delete" class="danger" type="button">
            Delete
          </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>

For a modal, call showModal() instead of setting the open attribute by hand. The open attribute alone can leave the background interactive, which breaks the modal contract.

Reusable React Component

In production, use one well-reviewed foundation for destructive actions, billing changes, invites, and short forms. This component works in Vite, a React SPA, or a Next.js Client Component. In Next.js App Router, add "use client"; at the top.

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="Close dialog"
            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%;
  }
}

Three Real Use Cases

Use caseWhy a modal fitsExtra work for Claude Code
Delete, cancel plan, change roleThe action is hard to undoDangerous copy, double-submit guard, audit log
Invite, billing, short settings formThe user can finish without leaving contextValidation, pending state, success focus
Command palette or quick searchFast action without page navigationArrow keys, aria-activedescendant, empty state

For destructive actions, decide whether backdrop clicks should cancel. For important operations, disabling backdrop close is often clearer. For short forms, keep the dialog open on validation errors and announce the error. For command palettes, treat the dialog shell and listbox behavior as separate responsibilities.

Promise-Based Confirmation

Admin screens often need if confirmed, then continue. This helper uses the ModalDialog above and lets callers write 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 ?? "Cancel"}
              </button>
              <button
                type="button"
                data-autofocus
                className={options.danger ? "danger" : "primary"}
                onClick={() => finish(true)}
              >
                {options.confirmLabel ?? "Confirm"}
              </button>
            </>
          }
        >
          <p>Review the details before continuing.</p>
        </ModalDialog>
      );
    }

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

async function handleDeleteProject(projectId: string) {
  const ok = await confirmDialog({
    title: "Delete this project?",
    message: "This action cannot be undone. Export your data first.",
    confirmLabel: "Delete",
    danger: true,
  });

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

Pitfalls and Accessibility Checks

The first common failure is a mouse-only close control. Use a real button, give icon-only buttons an accessible name, and check Enter and Space.

The second failure is removing the title because the design “does not need it.” The dialog still needs an accessible name. Connect the title with aria-labelledby, and use aria-describedby only for concise supporting text.

The third failure is hiding the focus ring. Do not ship outline: none without a replacement. Use :focus-visible so keyboard users can see where they are.

The fourth failure is stacking modal on modal. Nested confirmation flows make focus return, Escape, and responsibility unclear. Prefer clearer copy, undo, or a single well-designed confirmation step.

The fifth failure is mobile overflow. Long copy, the software keyboard, and fixed footers can push the primary action off-screen. Use max-height, overflow: auto, and a 320px manual check.

Playwright Smoke Test

Automation cannot prove screen reader quality, but it catches many focus regressions.

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: "Delete project" });
  await trigger.click();

  const dialog = page.getByRole("dialog", {
    name: "Delete this project?",
  });
  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();
});

Manual QA still matters. Navigate with Tab, Shift+Tab, Enter, Space, and Escape without touching the mouse. Then use NVDA on Windows or VoiceOver on macOS to check the title, description, and button names. Finally, inspect a narrow mobile viewport.

CTA and Revenue Use

Modals often sit close to revenue: checkout confirmation, lead forms, consultation requests, and email capture. That makes them valuable, but also risky. Do not interrupt readers with an ad-like modal when a toast, inline block, or normal page would be more respectful.

ClaudeCodeLab can help teams with Claude Code rollout, CLAUDE.md, accessible UI review, and React workflow cleanup through Claude Code training and consultation. Individual builders can start with products and the free cheatsheet to standardize prompts, reviews, tests, and permission boundaries.

Result

When Masa tested this pattern on a small React settings screen, the biggest improvement came from putting focus return and mobile width into the acceptance criteria before asking Claude Code for code. “Return focus to the opener,” “do not close destructive actions on backdrop click,” and “keep the footer usable at 320px” reduced review churn far more than styling instructions.

#Claude Code #modal #dialog #React #accessibility
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.