Dialog
shadcn Dialog (modal) — trigger, estado controlado, formularios dentro de diálogos y accesibilidad.
Receta
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.
Ejemplo funcional
"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:
- Diálogo controlado con
openyonOpenChange - Formulario dentro del diálogo con validación Zod
- Reinicio del formulario al abrir el diálogo
- Cerrar el diálogo tras un envío exitoso
- Etiquetas accesibles y mensajes de error
Análisis profundo
Cómo funciona
- Dialog está basado en
@radix-ui/react-dialog— totalmente accesible con focus trap y cierre con ESC DialogTriggerabre el diálogo al hacer clicDialogContentse renderiza en un portal, superponiéndose a la página con un fondo- El foco queda atrapado dentro del diálogo mientras está abierto — Tab recorre los elementos enfocables
- La tecla ESC y el clic en el fondo cierran el diálogo (configurable)
DialogCloserenderiza un botón que cierra el diálogo al hacer clicasChildfusiona las props del componente en su único elemento hijo
Variaciones
Diá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>Notas de TypeScript
// 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 divTrampas
-
El 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-autoaDialogContent. -
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
onOpenChangecuando el diálogo se abre.
Alternativas
| 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 |
Preguntas frecuentes
¿Cuál es la diferencia entre un Dialog no controlado y uno controlado?
- No controlado: usa solo
DialogTrigger; el Dialog gestiona su propio estado abierto/cerrado - Controlado: pasa las props
openyonOpenChangeaDialogpara control total del estado - Usa el modo controlado cuando necesites cerrar programáticamente (p. ej., después del envío del formulario)
¿Cómo cierras un Dialog después de un envío de formulario exitoso?
async function onSubmit(data: FormData) {
await saveData(data);
setOpen(false); // cerrar solo tras el éxito
}Usa estado controlado (open/onOpenChange) para decidir cuándo cerrar.
¿Qué biblioteca de Radix impulsa el Dialog de shadcn?
@radix-ui/react-dialog- Proporciona focus trap, cierre con tecla ESC, cierre al hacer clic en el fondo y renderizado en portal
¿Cómo evitas que el Dialog se cierre cuando el usuario hace clic fuera?
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
{/* El usuario debe hacer clic explícitamente en un botón de cierre */}
</DialogContent>Trampa: ¿Por qué el estado del formulario persiste entre aperturas del Dialog?
- El contenido del diálogo permanece montado entre aperturas por defecto
- Los valores antiguos del formulario permanecen a menos que reinicies explícitamente
- Solución: reinicia el estado del formulario en el callback
onOpenChangecuando el diálogo se abre
¿Cómo gestionas el desbordamiento en móvil en un Dialog con contenido largo?
- Añade
max-h-[85vh] overflow-y-autoaDialogContent - Esto evita que el diálogo supere la altura del viewport en pantallas pequeñas
¿Qué hace asChild en DialogTrigger?
- Fusiona el manejador de clic y las props de accesibilidad de DialogTrigger en su único hijo
- El elemento trigger (p. ej.,
<Button>) reemplaza al elemento renderizado por defecto - Sin
asChild, DialogTrigger renderiza su propio botón envolviendo tu hijo
¿Cómo tipas un componente reutilizable de diálogo de confirmación?
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title: string;
description: string;
}¿Qué es DialogClose y cuándo deberías usarlo?
DialogCloserenderiza un botón que cierra automáticamente el diálogo al hacer clic- Úsalo para botones de cancelar en diálogos no controlados
- En diálogos controlados, puedes usar un
Buttonnormal cononClick={() => setOpen(false)}en su lugar
Trampa: ¿Qué sucede cuando abres un dropdown o popover dentro de un Dialog?
- El focus trap puede entrar en conflicto con superposiciones en portal renderizadas fuera del diálogo
- Los componentes shadcn lo gestionan automáticamente porque también usan portal
- Las superposiciones personalizadas sin portal pueden quedar atrapadas u ocultas detrás del diálogo
¿Cómo implementas un diálogo de confirmación de eliminación con estado de carga asíncrono?
const [deleting, setDeleting] = useState(false);
async function handleDelete() {
setDeleting(true);
await onConfirm();
setDeleting(false);
setOpen(false);
}
// Deshabilita los botones Cancelar y Eliminar mientras se elimina¿Qué tipo de TypeScript deberías usar para las props del componente Dialog?
- Importa
DialogPropsde@radix-ui/react-dialogpara las props del componente raíz DialogContentextiendeDialogContentPropsde Radix y acepta todos los atributos HTML válidos de div