GitHub

Alert Dialog

A blocking confirmation dialog. Unlike Dialog, it cannot be dismissed by clicking the backdrop or pressing Escape — the user must explicitly choose an action.

AccessibleDark ModePortal3 VariantsLoading StateBlocking

Key difference from Dialog: No backdrop click dismiss, no Escape key dismiss, no × close button. Use this only for actions that require deliberate acknowledgement.

01

Theme Preview

Destructive variant — forced light and dark styling for direct comparison.

Light

Delete project?

This will permanently delete your project and all associated data.

Dark

Delete project?

This will permanently delete your project and all associated data.

02

Live Demo

Variant

Click the button to open the alert dialog. Notice the backdrop click and Escape key do not dismiss it.

03

Code Snippet

src/ui/AlertDialog.tsx
import { AlertDialog } from "@/ui/AlertDialog";

const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);

const handleConfirm = async () => {
  setLoading(true);
  await deleteItem();
  setLoading(false);
  setOpen(false);
};

<Button variant="destructive" onClick={() => setOpen(true)}>
  Delete project
</Button>

<AlertDialog.Root
  open={open}
  onClose={() => setOpen(false)}
  onConfirm={handleConfirm}
  title="Delete project?"
  description="This will permanently delete your project and all associated data. This action cannot be undone."
  confirmLabel="Delete project"
  cancelLabel="Keep it"
  variant="destructive"
  isLoading={loading}
/>

// Variants: "destructive" | "warning" | "default"

Bentuk lama <AlertDialog /> tetap jalan. Bentuk baru namespaced yang direkomendasikan adalah <AlertDialog.Root />.

04

Copy-Paste (Single File)

Versi ringkas self-contained yang bisa dipindahkan langsung ke project lain.

AlertDialog.tsx
"use client";

import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";

type ClassValue = string | false | null | undefined;
const cn = (...classes: ClassValue[]) => classes.filter(Boolean).join(" ");

type AlertDialogVariant = "destructive" | "warning" | "default";

interface AlertDialogProps {
  open: boolean;
  onClose: () => void;
  onConfirm: () => void;
  title: string;
  description: string;
  cancelLabel?: string;
  confirmLabel?: string;
  variant?: AlertDialogVariant;
  isLoading?: boolean;
}

const CONFIRM_CLASSES: Record<AlertDialogVariant, string> = {
  destructive: "bg-red-500 text-white hover:bg-red-600",
  warning: "bg-amber-500 text-white hover:bg-amber-600",
  default: "bg-blue-600 text-white hover:bg-blue-700",
};

function Spinner() {
  return (
    <svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none">
      <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
      <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

function AlertDialogRoot({
  open,
  onClose,
  onConfirm,
  title,
  description,
  cancelLabel = "Cancel",
  confirmLabel = "Confirm",
  variant = "default",
  isLoading = false,
}: AlertDialogProps) {
  useEffect(() => {
    if (!open) return;
    document.body.style.overflow = "hidden";
    return () => { document.body.style.overflow = ""; };
  }, [open]);

  if (!open) return null;

  return createPortal(
    <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
      <div className="absolute inset-0 bg-black/50" />
      <div role="alertdialog" aria-modal="true" className="relative w-full max-w-md rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-black/20">
        <div className="p-6">
          <h2 className="text-base font-semibold text-slate-900">{title}</h2>
          <p className="mt-1.5 text-sm leading-relaxed text-slate-500">{description}</p>
        </div>
        <div className="flex items-center justify-end gap-3 px-6 pb-6">
          <button onClick={onClose} disabled={isLoading} className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 disabled:opacity-50">
            {cancelLabel}
          </button>
          <button onClick={onConfirm} disabled={isLoading} className={cn("inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-70", CONFIRM_CLASSES[variant])}>
            {isLoading && <Spinner />}
            {confirmLabel}
          </button>
        </div>
      </div>
    </div>,
    document.body
  );
}

type AlertDialogCompound = typeof AlertDialogRoot & { Root: typeof AlertDialogRoot };

export const AlertDialog = Object.assign(AlertDialogRoot, { Root: AlertDialogRoot }) as AlertDialogCompound;

export type AlertDialogTriggerProps = {
  onClick: () => void;
  children: ReactNode;
};
05

Props

PropTypeDefaultDescription
openbooleanControls visibility.
onClose() => voidCalled when Cancel is clicked. Backdrop and Escape do NOT trigger this.
onConfirm() => voidCalled when the confirm button is clicked.
titlestringBold heading inside the dialog.
descriptionstringMuted supporting text below the title.
cancelLabelstring"Cancel"Label for the cancel button.
confirmLabelstring"Confirm"Label for the confirm button.
variant"destructive" | "warning" | "default""default"Controls icon, icon background, and confirm button colour.
isLoadingbooleanfalseShows a spinner and disables both buttons during async operations.
react-principles