Modal
Um diálogo de sobreposição que aparece sobre o conteúdo da página para capturar a atenção do usuário para confirmações, formulários ou informações importantes.
Busque em todas as páginas da documentação
Um diálogo de sobreposição que aparece sobre o conteúdo da página para capturar a atenção do usuário para confirmações, formulários ou informações importantes.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ open, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal();
} else {
dialog.close();
}
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
>
{children}
</dialog>
);
}Usa o elemento nativo <dialog> que fornece plano de fundo, captura de foco e tratamento da tecla Escape integrados. O modificador backdrop: do Tailwind estiliza a sobreposição atrás do diálogo.
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="w-full max-w-md rounded-xl bg-white p-0 shadow-xl backdrop:bg-black/50"
>
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button
onClick={onClose}
aria-label="Close"
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-6 py-4">{children}</div>
</dialog>
);
}Separa o cabeçalho e o corpo com uma borda. O botão de fechar usa aria-label para acessibilidade, pois contém apenas um ícone.
"use client";
import { useEffect, useRef } from "react";
interface ConfirmModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
loading?: boolean;
}
export function ConfirmModal({
open, onClose, onConfirm, title, message, confirmLabel = "Confirm", loading,
}: ConfirmModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-gray-600">{message}</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={loading}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{loading ? "Deleting..." : confirmLabel}
</button>
</div>
</dialog>
);
}Um diálogo de confirmação feito sob medida. O botão de ação destrutiva é vermelho e o botão de cancelar é visualmente mais claro para guiar o usuário para a escolha segura.
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer: React.ReactNode;
}
export function Modal({ open, onClose, title, children, footer }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="w-full max-w-lg rounded-xl bg-white p-0 shadow-xl backdrop:bg-black/50"
>
<div className="border-b px-6 py-4">
<h2 className="text-lg font-semibold">{title}</h2>
</div>
<div className="px-6 py-4">{children}</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">{footer}</div>
</dialog>
);
}Um layout baseado em slots com footer como prop. Isso permite que o componente pai forneça qualquer combinação de botões sem que o modal precise conhecer ações específicas.
"use client";
import { useEffect, useRef, useState } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ open, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal();
requestAnimationFrame(() => setVisible(true));
} else {
setVisible(false);
const timer = setTimeout(() => dialog.close(), 200);
return () => clearTimeout(timer);
}
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className={`w-full max-w-md rounded-xl bg-white p-6 shadow-xl transition-all duration-200 backdrop:bg-black/50 backdrop:transition-opacity backdrop:duration-200 ${
visible ? "scale-100 opacity-100 backdrop:opacity-100" : "scale-95 opacity-0 backdrop:opacity-0"
}`}
>
{children}
</dialog>
);
}Uma abordagem em duas fases: showModal() torna o diálogo visível no DOM, em seguida, requestAnimationFrame aciona a transição CSS. Ao fechar, a animação é reproduzida antes que dialog.close() o remova.
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
closeOnBackdrop?: boolean;
children: React.ReactNode;
}
export function Modal({ open, onClose, closeOnBackdrop = true, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
function handleClick(e: React.MouseEvent<HTMLDialogElement>) {
if (!closeOnBackdrop) return;
const rect = dialogRef.current?.getBoundingClientRect();
if (!rect) return;
const clickedOutside =
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom;
if (clickedOutside) onClose();
}
return (
<dialog
ref={dialogRef}
onClose={onClose}
onClick={handleClick}
className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
>
{children}
</dialog>
);
}O <dialog> nativo não fecha ao clicar no plano de fundo por padrão com showModal(). Você precisa implementar isso verificando as coordenadas do clique para simular o comportamento de fechar ao clicar no plano de fundo.
"use client";
import { useEffect, useRef, useState, useCallback, createContext, useContext } from "react";
import { createPortal } from "react-dom";
// --- Contexto para acesso aninhado ---
interface ModalContextValue {
close: () => void;
}
const ModalContext = createContext<ModalContextValue | null>(null);
export function useModal() {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error("useModal deve ser usado dentro de um Modal");
return ctx;
}
// --- Componente Modal ---
interface ModalProps {
open: boolean;
onClose: () => void;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
initialFocusRef?: React.RefObject<HTMLElement | null>;
children: React.ReactNode;
}
export function Modal({
open,
onClose,
closeOnBackdrop = true,
closeOnEscape = true,
initialFocusRef,
children,
}: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [visible, setVisible] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal();
requestAnimationFrame(() => {
setVisible(true);
if (initialFocusRef?.current) {
initialFocusRef.current.focus();
}
});
} else {
setVisible(false);
const timer = setTimeout(() => dialog.close(), 200);
return () => clearTimeout(timer);
}
}, [open, initialFocusRef]);
const handleCancel = useCallback(
(e: React.SyntheticEvent) => {
if (!closeOnEscape) {
e.preventDefault();
return;
}
onClose();
},
[closeOnEscape, onClose]
);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDialogElement>) => {
if (!closeOnBackdrop) return;
const rect = dialogRef.current?.getBoundingClientRect();
if (!rect) return;
const outside =
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom;
if (outside) onClose();
},
[closeOnBackdrop, onClose]
);
// Bloqueia a rolagem do corpo ao abrir
useEffect(() => {
if (open) {
const scrollY = window.scrollY;
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";
return () => {
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
window.scrollTo(0, scrollY);
};
}
}, [open]);
if (!mounted) return null;
return createPortal(
<ModalContext.Provider value={{ close: onClose }}>
<dialog
ref={dialogRef}
onCancel={handleCancel}
onClose={onClose}
onClick={handleClick}
className={`w-full max-w-lg rounded-xl bg-white p-0 shadow-2xl transition-all duration-200 backdrop:bg-black/50 backdrop:transition-opacity backdrop:duration-200 ${
visible
? "translate-y-0 scale-100 opacity-100 backdrop:opacity-100"
: "translate-y-2 scale-95 opacity-0 backdrop:opacity-0"
}`}
aria-modal="true"
>
{children}
</dialog>
</ModalContext.Provider>,
document.body
);
}
// --- Partes compostas ---
export function ModalHeader({ children }: { children: React.ReactNode }) {
const { close } = useModal();
return (
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold">{children}</h2>
<button
onClick={close}
aria-label="Close"
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
export function ModalBody({ children }: { children: React.ReactNode }) {
return <div className="px-6 py-4">{children}</div>;
}
export function ModalFooter({ children }: { children: React.ReactNode }) {
return <div className="flex justify-end gap-3 border-t px-6 py-4">{children}</div>;
}Aspectos chave:
<dialog> nativo com showModal() - fornece captura de foco integrada, tratamento da tecla Escape e semântica aria-modal. Não há necessidade de implementar uma armadilha de foco personalizada.ModalHeader, ModalBody, ModalFooter acessam a função de fechar através do contexto, mantendo a API composável.requestAnimationFrame - o diálogo é mostrado primeiro, depois animado na próxima quadro. Ao fechar, a animação é reproduzida por 200ms antes que dialog.close() seja chamado.onCancel - o diálogo nativo dispara um evento cancel ao pressionar Escape. Quando closeOnEscape é falso, preventDefault() o bloqueia.createPortal - renderiza o diálogo em document.body para evitar problemas de contexto de empilhamento z-index de componentes pais.initialFocusRef - permite que o chamador especifique qual elemento deve receber o foco quando o modal é aberto (por exemplo, um campo de entrada específico).Esquecer showModal() vs show() - show() abre o diálogo sem plano de fundo e sem captura de foco. Sempre use showModal() para diálogos modais.
Clique no plano de fundo não fecha por padrão - Ao contrário de muitas bibliotecas, o <dialog> nativo com showModal() não fecha ao clicar no plano de fundo. Você precisa implementar isso verificando as coordenadas do clique.
Vazamento de rolagem - A página atrás do modal ainda pode rolar no Safari móvel. Use o padrão de bloqueio de rolagem do corpo (posição fixa + deslocamento de rolagem salvo) para evitar isso.
Modais aninhados - Abrir um segundo <dialog> enquanto um já está aberto pode causar conflitos na armadilha de foco. Evite empilhar modais; use uma folha ou expansão inline em vez disso.
Manipulador onClose ausente - Se você não definir onClose, pressionar Escape fecha o diálogo visualmente, mas seu estado React permanece open: true, causando uma dessincronização. Sempre sincronize o callback onClose.
Animação na primeira montagem - Se o diálogo estiver aberto na montagem, a animação é reproduzida a partir do estado inicial. Use requestAnimationFrame ou um sinalizador mounted para pular a animação de entrada quando apropriado.
Revisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥