Tips & Tricks (Atualizado: 02/06/2026)

Criar modais acessíveis com Claude Code: React, dialog e foco

Guia para criar modais com Claude Code: dialog, React, foco, falhas comuns, testes e acessibilidade.

Criar modais acessíveis com Claude Code: React, dialog e foco

Um diálogo modal é uma camada temporária sobre a página atual. Ele pede uma decisão ou uma entrada curta e depois devolve o usuário ao fluxo anterior. O ponto difícil não é centralizar uma caixa: é bloquear o fundo, mover o foco para dentro, fechar de forma previsível e devolver o foco ao botão que abriu o modal.

Se você pedir ao Claude Code “faça um modal bonito”, ele pode entregar algo visualmente correto, mas frágil: Escape não fecha, Tab escapa para o fundo, o leitor de tela não anuncia o título ou os botões somem no celular. Este guia transforma o pedido em um brief prático, exemplos copiáveis, casos de uso, falhas e testes.

Use referências oficiais na revisão: MDN sobre o elemento <dialog>, WAI-ARIA APG Modal Dialog Pattern, WCAG Focus Order e Focus Visible. No ClaudeCodeLab, leia também acessibilidade, Radix UI, command palette e toast notifications.

Antes de implementar

Modal combina com tarefas curtas que precisam pausar a página: confirmar exclusão, cancelar plano, alterar papel, convidar membro, fazer login antes do checkout ou abrir uma paleta de comandos.

Ele não combina com formulários longos, textos legais completos, fluxos de várias páginas, anúncios agressivos nem avisos que podem ser lidos depois. Antes do código, decida se a ação merece bloquear a página, onde o foco entra, quais ações fecham e se tudo funciona com 320px de largura.

Definições simples ajudam Claude Code. Foco é a posição atual do teclado. Focus trap mantém Tab dentro do modal. inert significa que o fundo não é interativo. ARIA descreve a UI para tecnologias assistivas.

flowchart TD
  A["Usuário aciona o botão"] --> B["Abrir com dialog.showModal()"]
  B --> C["Mover foco para título ou primeira ação"]
  C --> D["Verificar Tab, Shift+Tab e Escape"]
  D --> E["Separar confirmar, cancelar e clique fora"]
  E --> F["Devolver foco ao gatilho"]

Brief para Claude Code

Comportamento primeiro, estilo depois.

Adicione um diálogo modal à tela React + TypeScript existente.

Requisitos:
- Leia botões, formulários, CSS e testes antes de editar.
- Priorize o elemento HTML dialog. Explique se ele não servir.
- Ao abrir, mova o foco para o título ou a primeira ação útil.
- Trate Escape, cancelar, confirmar e clique externo separadamente.
- Ao fechar, devolva o foco ao botão que abriu o modal.
- Use aria-labelledby e aria-describedby quando houver descrição curta.
- Não remova outline. Use :focus-visible para foco visível.
- Em 320px, conteúdo e botões precisam continuar usáveis.
- Deixe falhas comuns e verificação manual no handoff.

Arquivos permitidos:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts

HTML executável

Salve como modal-demo.html e abra no navegador.

<!doctype html>
<html lang="pt">
  <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>Configurações do projeto</h1>
      <p>Use modal apenas para ações que precisam pausar a página.</p>
      <button id="open-dialog" class="danger" type="button">
        Excluir projeto
      </button>
    </main>

    <dialog id="confirm-dialog" aria-labelledby="dialog-title">
      <div class="modal-body">
        <h2 id="dialog-title" tabindex="-1">Excluir este projeto?</h2>
        <p>Esta ação não pode ser desfeita. Exporte os dados se precisar.</p>
        <div class="button-row">
          <button id="cancel-dialog" type="button">Cancelar</button>
          <button id="confirm-delete" class="danger" type="button">
            Excluir
          </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>

Para abrir como modal, chame showModal() em vez de adicionar open manualmente. Só open pode deixar o fundo interativo.

Componente React reutilizável

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="Fechar diálogo"
            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,
.app-modal__footer {
  display: flex;
  gap: 12px;
}

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

.app-modal__footer {
  flex-wrap: wrap;
  justify-content: flex-end;
}

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

Três casos reais

CasoPor que usar modalO que pedir ao Claude Code
Excluir, cancelar, mudar papelAção difícil de desfazerTexto de risco, bloqueio de duplo envio, log
Convite, cobrança, configuração curtaTermina sem sair do contextoValidação, estado pendente, foco após sucesso
Paleta de comandos ou buscaAção rápida sem navegarSetas, aria-activedescendant, estado vazio

Em ações destrutivas, decida se clique fora deve fechar. Em formulários curtos, mantenha aberto quando houver erro e anuncie a mensagem.

Confirmação com Promise

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 ?? "Cancelar"}
              </button>
              <button
                type="button"
                data-autofocus
                className={options.danger ? "danger" : "primary"}
                onClick={() => finish(true)}
              >
                {options.confirmLabel ?? "Confirmar"}
              </button>
            </>
          }
        >
          <p>Revise os detalhes antes de continuar.</p>
        </ModalDialog>
      );
    }

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

Falhas comuns

Primeiro, botão de fechar que só funciona com mouse. Use button, nome acessível e teste Enter e Space.

Segundo, remover o título por estética. O diálogo precisa de nome acessível com aria-labelledby.

Terceiro, outline: none sem alternativa. Use :focus-visible.

Quarto, empilhar modais. Prefira uma confirmação clara ou desfazer.

Quinto, esquecer mobile. Use max-height, overflow: auto e teste 320px.

Teste mínimo com 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: "Excluir projeto" });
  await trigger.click();

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

Ainda faça revisão manual com teclado, NVDA ou VoiceOver e viewport móvel estreito.

CTA e monetização

Modais ficam perto de receita: checkout, formulário de lead, consulta e captura de email. Por isso precisam ser úteis, não intrusivos.

Para equipes que querem organizar Claude Code, CLAUDE.md, revisão de UI acessível e fluxos React, veja treinamento e consultoria Claude Code. Para começar sozinho, use produtos e a folha gratuita.

Resultado

Masa testou este padrão em uma tela pequena de configurações React. Os critérios “devolver foco ao gatilho”, “não fechar ação perigosa por clique fora” e “manter botões usáveis a 320px” reduziram mais retrabalho do que instruções visuais.

#Claude Code #modal #dialogo #React #acessibilidade
Grátis

PDF grátis: cheatsheet do Claude Code

Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.

Cuidamos dos seus dados e não enviamos spam.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.