← Componentes

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

Variant
Size
<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

ComponentePropTipoDefault
Dialogopenboolean
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void
DialogTriggerasChildbooleanfalse
DialogContentvariantcenter | rightcenter
sizesm | md | lg | xlmd
hideCloseButtonbooleanfalse
DialogCloseasChildbooleanfalse
Header / Title / Description / Footeratributos 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).