Dialog
shadcn Dialog (modal) - trigger, estado controlado, formularios dentro de diálogos y accesibilidad.
Busca en todas las páginas de la documentación
shadcn Dialog (modal) - trigger, estado controlado, formularios dentro de diálogos y accesibilidad.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida - lista para copiar y pegar.
npx shadcn@latest add dialog button input labelimport {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle, DialogTrigger, DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
// Diálogo básico
<Dialog>
<DialogTrigger asChild>
<Button>Abrir diálogo</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>¿Estás seguro?</DialogTitle>
<DialogDescription>Esta acción no se puede deshacer.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancelar</Button>
</DialogClose>
<Button>Confirmar</Button>
</DialogFooter>
</DialogContent>
</Dialog>Cuándo usarlo: Cuando necesitas una superposición modal para confirmaciones, formularios, vistas de detalle o cualquier contenido que requiera la atención focalizada del usuario.
"use client";
import { useState } from "react";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const EditProfileSchema = z.object({
name: z.string().min(1, "El nombre es obligatorio"),
email: z.string().email("Correo electrónico no válido"),
bio: z.string().max(200, "La biografía debe tener menos de 200 caracteres").optional(),
});
type EditProfileData = z.infer<typeof EditProfileSchema>;
export function EditProfileDialog({
profile,
onSave,
}: {
profile: EditProfileData;
onSave: (data: EditProfileData) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<EditProfileData>({
resolver: zodResolver(EditProfileSchema),
defaultValues: profile,
});
async function onSubmit(data: EditProfileData) {
await onSave(data);
setOpen(false);
}
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (nextOpen) {
reset(profile); // reiniciar formulario al abrir
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline">Editar perfil</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Editar perfil</DialogTitle>
<DialogDescription>
Actualiza la información de tu perfil. Haz clic en guardar cuando termines.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Nombre</Label>
<Input id="name" {...register("name")} />
{errors.name && (
<p className="text-sm text-red-600">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Correo electrónico</Label>
<Input id="email" type="email" {...register("email")} />
{errors.email && (
<p className="text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="bio">Biografía</Label>
<Input id="bio" {...register("bio")} placeholder="Cuéntanos sobre ti" />
{errors.bio && (
<p className="text-sm text-red-600">{errors.bio.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Guardando..." : "Guardar"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}Lo que esto demuestra:
open y onOpenChange@radix-ui/react-dialog - totalmente accesible con focus trap y cierre con ESCDialogTrigger abre el diálogo al hacer clicDialogContent se renderiza en un portal, superponiéndose a la página con un fondoDialogClose renderiza un botón que cierra el diálogo al hacer clicasChild fusiona las props del componente en su único elemento hijoDiálogo de confirmación con acción asíncrona:
function DeleteConfirmDialog({
onConfirm,
itemName,
}: {
onConfirm: () => Promise<void>;
itemName: string;
}) {
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
async function handleDelete() {
setDeleting(true);
await onConfirm();
setDeleting(false);
setOpen(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">Eliminar</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>¿Eliminar {itemName}?</DialogTitle>
<DialogDescription>
Esta acción no se puede deshacer. Esto eliminará permanentemente el elemento.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={deleting}>
Cancelar
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting ? "Eliminando..." : "Eliminar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}Diálogo con contenido desplazable:
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Términos de servicio</DialogTitle>
</DialogHeader>
<div className="prose max-w-none text-sm">
{/* Contenido largo */}
</div>
</DialogContent>Evitar el cierre al hacer clic en el fondo:
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
{/* El usuario debe cerrar explícitamente */}
</DialogContent>// Props del componente Dialog
import type { DialogProps } from "@radix-ui/react-dialog";
// Wrapper de diálogo controlado
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title: string;
description: string;
}
// DialogContent extiende Radix DialogContentProps
// Puedes pasar cualquier atributo HTML válido de divEl envío del formulario cierra el diálogo - Si la acción del formulario navega o el estado cambia, el diálogo puede cerrarse inesperadamente. Solución: Usa estado controlado y cierra solo después de que la acción termine.
Focus trap con portales - Si renderizas un dropdown o popover dentro de un diálogo, asegúrate de que también se renderice correctamente en portal. Los componentes shadcn lo gestionan automáticamente.
Desplazamiento en móvil - El contenido del diálogo puede desbordarse en pantallas pequeñas. Solución: Añade max-h-[85vh] overflow-y-auto a DialogContent.
Apilamiento de varios diálogos - Abrir un diálogo desde dentro de otro funciona, pero puede confundir a los usuarios. Solución: Usa un único diálogo con contenido dinámico, o asegura un apilamiento correcto de z-index.
Reinicio del formulario al cerrar - El contenido del diálogo permanece montado entre aperturas por defecto. Solución: Reinicia el estado del formulario en onOpenChange cuando el diálogo se abre.
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
| Sheet (panel lateral) | Quieres un panel deslizante en lugar de un modal centrado | Un diálogo centrado es más apropiado |
| Alert Dialog | La acción es destructiva y no debe descartarse accidentalmente | El diálogo puede cerrarse haciendo clic fuera |
| Popover | Necesitas una superposición pequeña y no bloqueante cerca de un trigger | Necesitas bloqueo de foco a pantalla completa |
| Drawer | Quieres un bottom sheet apto para móvil | La experiencia prioriza escritorio |
DialogTrigger; el Dialog gestiona su propio estado abierto/cerradoopen y onOpenChange a Dialog para control total del estadoasync function onSubmit(data: FormData) {
await saveData(data);
setOpen(false); // cerrar solo tras el éxito
}Usa estado controlado (open/onOpenChange) para decidir cuándo cerrar.
@radix-ui/react-dialog<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
{/* El usuario debe hacer clic explícitamente en un botón de cierre */}
</DialogContent>onOpenChange cuando el diálogo se abremax-h-[85vh] overflow-y-auto a DialogContent<Button>) reemplaza al elemento renderizado por defectoasChild, DialogTrigger renderiza su propio botón envolviendo tu hijointerface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title: string;
description: string;
}DialogClose renderiza un botón que cierra automáticamente el diálogo al hacer clicButton normal con onClick={() => setOpen(false)} en su lugarconst [deleting, setDeleting] = useState(false);
async function handleDelete() {
setDeleting(true);
await onConfirm();
setDeleting(false);
setOpen(false);
}
// Deshabilita los botones Cancelar y Eliminar mientras se eliminaDialogProps de @radix-ui/react-dialog para las props del componente raízDialogContent extiende DialogContentProps de Radix y acepta todos los atributos HTML válidos de divRevisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥