Overlays

Alert Dialog

A confirm-destructive dialog: alertdialog semantics, cancel focused by default, outside clicks never dismiss, house modal physics.

Install

npx shadcn@latest add @paragon/alert-dialog

Also installs: button

alert-dialog.tsx

"use client";

import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "@/registry/paragon/ui/button";

/*
 * Confirm-destructive dialog built on Radix Dialog with alertdialog
 * semantics: role="alertdialog", outside interactions never dismiss,
 * and the cancel button receives initial focus so Enter can't destroy
 * anything by default. Same modal physics as `dialog` — center-origin
 * scale 0.97 -> 1 at 200ms, 150ms non-retracing exit.
 */
const alertDialogStyles = `
@keyframes pg-alert-overlay-in { from { opacity: 0; } }
@keyframes pg-alert-overlay-out { to { opacity: 0; } }
@keyframes pg-alert-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-alert-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-alert-in { from { opacity: 0; } }
  @keyframes pg-alert-out { to { opacity: 0; } }
}
`;

interface AlertDialogContextValue {
  cancelRef: React.RefObject<HTMLButtonElement | null>;
}

const AlertDialogContext = React.createContext<AlertDialogContextValue | null>(
  null,
);

function AlertDialog(
  props: React.ComponentProps<typeof DialogPrimitive.Root>,
) {
  const cancelRef = React.useRef<HTMLButtonElement>(null);
  const context = React.useMemo(() => ({ cancelRef }), []);
  return (
    <AlertDialogContext.Provider value={context}>
      <DialogPrimitive.Root data-slot="alert-dialog" {...props} />
    </AlertDialogContext.Provider>
  );
}

function AlertDialogTrigger(
  props: React.ComponentProps<typeof DialogPrimitive.Trigger>,
) {
  return (
    <DialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
  );
}

function AlertDialogContent({
  className,
  onOpenAutoFocus,
  onInteractOutside,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
  const context = React.useContext(AlertDialogContext);

  return (
    <DialogPrimitive.Portal>
      <style href="paragon-alert-dialog" precedence="paragon">{alertDialogStyles}</style>
      <DialogPrimitive.Overlay
        data-slot="alert-dialog-overlay"
        className={cn(
          "fixed inset-0 z-50 bg-black/40 dark:bg-black/60",
          "data-[state=open]:animate-[pg-alert-overlay-in_200ms_var(--ease-out)]",
          "data-[state=closed]:animate-[pg-alert-overlay-out_150ms_var(--ease-exit)_forwards]",
        )}
      />
      <DialogPrimitive.Content
        data-slot="alert-dialog-content"
        role="alertdialog"
        onOpenAutoFocus={(event) => {
          onOpenAutoFocus?.(event);
          if (event.defaultPrevented) return;
          if (context?.cancelRef.current) {
            event.preventDefault();
            context.cancelRef.current.focus();
          }
        }}
        onInteractOutside={(event) => {
          onInteractOutside?.(event);
          // Alert dialogs require an explicit choice — clicks outside never dismiss.
          event.preventDefault();
        }}
        className={cn(
          "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-card p-6 text-card-foreground shadow-overlay outline-none sm:max-w-md",
          "data-[state=open]:animate-[pg-alert-in_200ms_var(--ease-out)]",
          "data-[state=closed]:animate-[pg-alert-out_150ms_var(--ease-exit)_forwards]",
          className,
        )}
        {...props}
      />
    </DialogPrimitive.Portal>
  );
}

function AlertDialogHeader({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="alert-dialog-header"
      className={cn("flex flex-col gap-1.5", className)}
      {...props}
    />
  );
}

function AlertDialogFooter({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="alert-dialog-footer"
      className={cn(
        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
        className,
      )}
      {...props}
    />
  );
}

function AlertDialogTitle({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
  return (
    <DialogPrimitive.Title
      data-slot="alert-dialog-title"
      className={cn("text-base leading-none font-semibold", className)}
      {...props}
    />
  );
}

function AlertDialogDescription({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
  return (
    <DialogPrimitive.Description
      data-slot="alert-dialog-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  );
}

/** The destructive confirm button. Closes the dialog on click. */
function AlertDialogAction({
  variant = "destructive",
  ...props
}: ButtonProps) {
  return (
    <DialogPrimitive.Close asChild>
      <Button data-slot="alert-dialog-action" variant={variant} {...props} />
    </DialogPrimitive.Close>
  );
}

/** The safe way out. Receives focus when the dialog opens. */
function AlertDialogCancel({ variant = "outline", ...props }: ButtonProps) {
  const context = React.useContext(AlertDialogContext);
  return (
    <DialogPrimitive.Close asChild>
      <Button
        data-slot="alert-dialog-cancel"
        ref={context?.cancelRef}
        variant={variant}
        {...props}
      />
    </DialogPrimitive.Close>
  );
}

export {
  AlertDialog,
  AlertDialogTrigger,
  AlertDialogContent,
  AlertDialogHeader,
  AlertDialogFooter,
  AlertDialogTitle,
  AlertDialogDescription,
  AlertDialogAction,
  AlertDialogCancel,
};