Dialog
Contenido libre — un formulario, ajustes, cualquier cosa que no sea una decisión de sí/no. A diferencia de Alert Dialog (que fuerza una respuesta), este sí se cierra clickeando afuera, con Esc o con la × — aquí arrepentirse a medias es una opción válida. Comparten la misma mecánica por dentro (portal, trampa de foco, el gesto de apertura), pero no comparten componente: son contratos de interacción distintos, no una diferencia de estilo.
Playground
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Editar perfil</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar perfil</DialogTitle>
<DialogDescription>Se guarda apenas cierras.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cerrar</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>Variantes
variant en DialogContent — aquí no es color como en Alert Dialog, es geometría: center es el modal de siempre; right es un panel lateral (drawer) para formularios largos que no necesitan tapar todo el contexto. El panel lateral solo expone su borde izquierdo — dos marcas de esquina en vez de cuatro, mismo lenguaje.
center
right
<DialogContent variant="right" size="lg">
{/* mismo Header/Footer — el panel lateral no cambia la API, solo la geometría */}
</DialogContent>Código
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
import { useDialogTransition } from "./lib/use-dialog-transition";
// debe calzar con duration-200 de la transición de salida — ver la misma
// nota en alert-dialog.tsx, es la misma mecánica compartida
const EXIT_MS = 200;
type DialogContextValue = { open: boolean; setOpen: (open: boolean) => void };
const DialogContext = React.createContext<DialogContextValue | null>(null);
function useDialogContext(component: string) {
const ctx = React.useContext(DialogContext);
if (!ctx) throw new Error(`${component} debe usarse dentro de Dialog`);
return ctx;
}
export type DialogVariant = "center" | "right";
export type DialogSize = "sm" | "md" | "lg" | "xl";
const DialogContentContext = React.createContext<{ titleId: string; descriptionId: string } | null>(null);
const sizeVariants: Record<DialogSize, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
export interface DialogProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}
export function Dialog({ open: controlledOpen, defaultOpen = false, onOpenChange, children }: DialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
const value = React.useMemo(() => ({ open, setOpen }), [open, setOpen]);
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
}
export interface DialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** clona el hijo en vez de envolverlo en un <button> propio — para usar Button como trigger sin anidar botones */
asChild?: boolean;
}
export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
({ asChild = false, children, onClick, ...props }, ref) => {
const { setOpen } = useDialogContext("DialogTrigger");
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<{ onClick?: React.MouseEventHandler }>;
return React.cloneElement(child, {
onClick: (e: React.MouseEvent) => {
child.props.onClick?.(e);
setOpen(true);
},
});
}
return (
<button
ref={ref}
type="button"
onClick={(e) => {
onClick?.(e);
setOpen(true);
}}
{...props}
>
{children}
</button>
);
},
);
DialogTrigger.displayName = "DialogTrigger";
function CloseIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
className={className}
aria-hidden="true"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
}
export interface DialogCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** clona el hijo en vez de envolverlo en un <button> propio — para usar Button sin anidar botones */
asChild?: boolean;
}
export const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(
({ asChild = false, onClick, className, children, ...props }, ref) => {
const { setOpen } = useDialogContext("DialogClose");
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<{ onClick?: React.MouseEventHandler }>;
return React.cloneElement(child, {
onClick: (e: React.MouseEvent) => {
child.props.onClick?.(e);
setOpen(false);
},
});
}
return (
<button
ref={ref}
type="button"
onClick={(e) => {
onClick?.(e);
setOpen(false);
}}
className={className}
{...props}
>
{children}
</button>
);
},
);
DialogClose.displayName = "DialogClose";
const cornersCenter = [
{ position: "-left-2 -top-2", border: "border-l border-t", offset: "translate(-6px,-6px)" },
{ position: "-right-2 -top-2", border: "border-r border-t", offset: "translate(6px,-6px)" },
{ position: "-left-2 -bottom-2", border: "border-l border-b", offset: "translate(-6px,6px)" },
{ position: "-right-2 -bottom-2", border: "border-r border-b", offset: "translate(6px,6px)" },
] as const;
// el panel "right" queda a ras del viewport en tres lados — solo el borde
// izquierdo está expuesto, así que solo esas dos esquinas tienen marca
const cornersRight = [
{ position: "-left-2 top-6", border: "border-l border-t", offset: "translate(-10px,0)" },
{ position: "-left-2 bottom-6", border: "border-l border-b", offset: "translate(-10px,0)" },
] as const;
export interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
/** center: modal centrado. right: panel lateral tipo drawer, útil para formularios largos sin tapar todo el contexto */
variant?: DialogVariant;
size?: DialogSize;
/** oculta la × por defecto — para cuando el cierre ya está resuelto por otro botón */
hideCloseButton?: boolean;
}
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
(
{ className, children, variant = "center", size = "md", hideCloseButton = false, ...props },
ref,
) => {
const { open, setOpen } = useDialogContext("DialogContent");
const { portalReady, rendered, entered, panelRef } = useDialogTransition(open, setOpen, EXIT_MS);
const titleId = React.useId();
const descriptionId = React.useId();
if (!portalReady || !rendered) return null;
const corners = variant === "right" ? cornersRight : cornersCenter;
return createPortal(
<DialogContentContext.Provider value={{ titleId, descriptionId }}>
<div className={cn("fixed inset-0 z-50", variant === "center" && "flex items-center justify-center p-4")}>
{/* obsidiana plana, sin blur. a diferencia de AlertDialog, clickear
aquí SÍ cierra — un dialog general admite arrepentirse a medias */}
<div
aria-hidden="true"
onClick={() => setOpen(false)}
className={cn(
"absolute inset-0 bg-background/80 transition-opacity",
EASE,
entered ? "opacity-100 duration-300" : "opacity-0 duration-200",
)}
/>
<div
ref={(node) => {
panelRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
className={cn(
"relative border-border bg-background p-6 transition-all",
EASE,
variant === "center" &&
cn(
"w-full rounded-md border",
sizeVariants[size],
entered
? "opacity-100 scale-100 translate-y-0 duration-300"
: "opacity-0 scale-95 translate-y-2 duration-200",
),
variant === "right" &&
cn(
"fixed inset-y-0 right-0 h-full w-full rounded-l-md border-l",
sizeVariants[size],
entered ? "translate-x-0 duration-300" : "translate-x-full duration-200",
),
className,
)}
{...props}
>
{/* marcas de esquina — mismo gesto que AlertDialog, adaptado a
la geometría del panel (4 esquinas centrado, 2 en el lateral) */}
{corners.map((corner) => (
<span
key={corner.position}
aria-hidden="true"
className={cn(
"pointer-events-none absolute h-4 w-4 border-primary/70 transition-all",
EASE,
corner.position,
corner.border,
entered ? "opacity-100 duration-300 delay-150" : "opacity-0 duration-200",
)}
style={{ transform: entered ? "translate(0,0)" : corner.offset }}
/>
))}
{!hideCloseButton && (
<DialogClose
aria-label="Cerrar"
className="absolute right-4 top-4 text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<CloseIcon className="h-4 w-4" />
</DialogClose>
)}
{children}
</div>
</div>
</DialogContentContext.Provider>,
document.body,
);
},
);
DialogContent.displayName = "DialogContent";
export const DialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1.5 pr-6", className)} {...props} />
),
);
DialogHeader.displayName = "DialogHeader";
export const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, id, ...props }, ref) => {
const ctx = React.useContext(DialogContentContext);
return <h2 ref={ref} id={id ?? ctx?.titleId} className={cn("text-lg font-semibold", className)} {...props} />;
},
);
DialogTitle.displayName = "DialogTitle";
export const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, id, ...props }, ref) => {
const ctx = React.useContext(DialogContentContext);
return (
<p ref={ref} id={id ?? ctx?.descriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />
);
},
);
DialogDescription.displayName = "DialogDescription";
export const DialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("mt-6 flex justify-end gap-3", className)} {...props} />
),
);
DialogFooter.displayName = "DialogFooter";Código real de packages/ui/src/dialog.tsx. Comparte use-dialog-transition.ts con Alert Dialog — el portal, la trampa de foco y el timing de entrada/salida viven en un solo lugar.
Uso
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "./components/ui/dialog";
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Editar perfil</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar perfil</DialogTitle>
<DialogDescription>Se guarda apenas cierras.</DialogDescription>
</DialogHeader>
{/* formulario aquí */}
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cerrar</Button>
</DialogClose>
<Button onClick={guardar}>Guardar</Button>
</DialogFooter>
</DialogContent>
</Dialog>API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| Dialog | open | boolean | — |
| defaultOpen | boolean | false | |
| onOpenChange | (open: boolean) => void | — | |
| DialogTrigger | asChild | boolean | false |
| DialogContent | variant | center | right | center |
| size | sm | md | lg | xl | md | |
| hideCloseButton | boolean | false | |
| DialogClose | asChild | boolean | false |
| Header / Title / Description / Footer | — | atributos nativos de su elemento | |
Sin DialogAction — a diferencia de Alert Dialog, aquí el footer es contenido libre. Un botón que cierra usa DialogClose asChild; uno que guarda es un Button normal que cierra cuando tú decidas (después de que la acción termine, no antes).