Web InnoventixFreeCode

Confirm Modal

Original · free

confirm/destructive dialog

byWeb InnoventixReact + Tailwind
modalconfirmmodals
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/modal-confirm.json
modal-confirm.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import type {
  KeyboardEvent as ReactKeyboardEvent,
  MouseEvent as ReactMouseEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type DialogTone = "danger" | "default";

interface DangerAction {
  id: string;
  tone: DialogTone;
  rowTitle: string;
  rowDescription: string;
  rowButton: string;
  dialogTitle: string;
  dialogDescription: string;
  confirmLabel: string;
  cancelLabel: string;
  confirmPhrase?: string;
  successMessage: string;
}

const ACTIONS: DangerAction[] = [
  {
    id: "delete-project",
    tone: "danger",
    rowTitle: "Delete this project",
    rowDescription:
      "Removes the Invoicing API project, its 3 environments and every deployment log. This action cannot be undone.",
    rowButton: "Delete project",
    dialogTitle: "Delete “Invoicing API”?",
    dialogDescription:
      "This permanently deletes the project, all 3 environments, 128 deployments and revokes 14 active API keys. There is no recovery once this completes.",
    confirmLabel: "Delete project",
    cancelLabel: "Keep project",
    confirmPhrase: "invoicing-api",
    successMessage:
      "Project “Invoicing API” and all of its data were permanently deleted.",
  },
  {
    id: "remove-collaborators",
    tone: "danger",
    rowTitle: "Remove inactive collaborators",
    rowDescription:
      "Revoke workspace access for 4 members who haven’t signed in for 90+ days.",
    rowButton: "Remove 4 people",
    dialogTitle: "Remove 4 collaborators?",
    dialogDescription:
      "They will immediately lose access to every repository and shared secret. You can re-invite them at any time.",
    confirmLabel: "Remove members",
    cancelLabel: "Cancel",
    successMessage: "Removed 4 collaborators from the workspace.",
  },
  {
    id: "publish-release",
    tone: "default",
    rowTitle: "Publish release v2.8.0",
    rowDescription:
      "Promote the staged build to production and notify 2,400 changelog subscribers.",
    rowButton: "Publish to production",
    dialogTitle: "Publish v2.8.0 to production?",
    dialogDescription:
      "The staged build goes live for all users immediately and a changelog email is sent to 2,400 subscribers.",
    confirmLabel: "Publish now",
    cancelLabel: "Not yet",
    successMessage: "Release v2.8.0 is now live in production.",
  },
];

const FOCUSABLE =
  'a[href],button:not([disabled]),textarea,input:not([disabled]),select,[tabindex]:not([tabindex="-1"])';

function WarningIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5">
      <path
        fill="none"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M12 3.2 21 19H3L12 3.2Z M12 10v4 M12 17h.01"
      />
    </svg>
  );
}

function RocketIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5">
      <path
        fill="none"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M5 15c-1.2.6-2 2-2 4 2 0 3.4-.8 4-2m1-6.5C8.5 7 12 4 18 4c1 0 2 .1 2 .1s.1 1 .1 2c0 6-3 9.5-6.5 10m-6.1-4.6 4.6 4.6M14 10.5a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0Z"
      />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
      <path
        fill="none"
        stroke="currentColor"
        strokeWidth="2.2"
        strokeLinecap="round"
        strokeLinejoin="round"
        d="m5 12.5 4.5 4.5L19 6.5"
      />
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
      <path
        fill="none"
        stroke="currentColor"
        strokeWidth="1.9"
        strokeLinecap="round"
        d="M6 6l12 12M18 6 6 18"
      />
    </svg>
  );
}

function Spinner() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="cnfrm-spin h-4 w-4">
      <circle
        cx="12"
        cy="12"
        r="9"
        fill="none"
        stroke="currentColor"
        strokeWidth="2.4"
        strokeOpacity="0.3"
      />
      <path
        d="M21 12a9 9 0 0 0-9-9"
        fill="none"
        stroke="currentColor"
        strokeWidth="2.4"
        strokeLinecap="round"
      />
    </svg>
  );
}

interface ConfirmDialogProps {
  action: DangerAction;
  typed: string;
  pending: boolean;
  onTypedChange: (value: string) => void;
  onCancel: () => void;
  onConfirm: () => void;
}

function ConfirmDialog({
  action,
  typed,
  pending,
  onTypedChange,
  onCancel,
  onConfirm,
}: ConfirmDialogProps) {
  const reduce = useReducedMotion();
  const rawId = useId();
  const titleId = `${rawId}-title`;
  const descId = `${rawId}-desc`;
  const inputId = `${rawId}-input`;
  const hintId = `${rawId}-hint`;

  const panelRef = useRef<HTMLDivElement>(null);
  const cancelRef = useRef<HTMLButtonElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  const isDanger = action.tone === "danger";
  const needsPhrase = Boolean(action.confirmPhrase);
  const phraseOk = !needsPhrase || typed.trim() === action.confirmPhrase;
  const confirmDisabled = pending || !phraseOk;

  useEffect(() => {
    const target = needsPhrase ? inputRef.current : cancelRef.current;
    const raf = requestAnimationFrame(() => target?.focus());
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      cancelAnimationFrame(raf);
      document.body.style.overflow = previousOverflow;
    };
  }, [needsPhrase]);

  const handleKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLDivElement>) => {
      if (event.key === "Escape") {
        if (pending) return;
        event.stopPropagation();
        onCancel();
        return;
      }
      if (event.key !== "Tab") return;
      const panel = panelRef.current;
      if (!panel) return;
      const nodes = Array.from(
        panel.querySelectorAll<HTMLElement>(FOCUSABLE),
      ).filter((node) => node.offsetParent !== null);
      if (nodes.length === 0) return;
      const first = nodes[0];
      const last = nodes[nodes.length - 1];
      const activeEl = document.activeElement;
      if (event.shiftKey && activeEl === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && activeEl === last) {
        event.preventDefault();
        first.focus();
      }
    },
    [onCancel, pending],
  );

  const handleBackdrop = useCallback(
    (event: ReactMouseEvent<HTMLDivElement>) => {
      if (event.target === event.currentTarget && !pending) onCancel();
    },
    [onCancel, pending],
  );

  const confirmClasses = isDanger
    ? "bg-rose-600 text-white hover:bg-rose-500 focus-visible:ring-rose-500 shadow-rose-900/30"
    : "bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:ring-indigo-500 shadow-indigo-900/30";

  return (
    <motion.div
      className="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-slate-950/55 p-4 backdrop-blur-sm sm:p-6"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      transition={{ duration: reduce ? 0 : 0.18 }}
      onMouseDown={handleBackdrop}
      onKeyDown={handleKeyDown}
    >
      <motion.div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={descId}
        className="relative w-full max-w-md overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-slate-950/25 dark:border-white/10 dark:bg-neutral-900"
        initial={
          reduce ? { opacity: 0 } : { opacity: 0, y: 14, scale: 0.96 }
        }
        animate={{ opacity: 1, y: 0, scale: 1 }}
        exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }}
        transition={{
          duration: reduce ? 0 : 0.24,
          ease: [0.16, 1, 0.3, 1],
        }}
      >
        <span
          aria-hidden="true"
          className={`absolute inset-x-0 top-0 h-1 ${
            isDanger
              ? "bg-gradient-to-r from-rose-500 via-rose-400 to-amber-400"
              : "bg-gradient-to-r from-indigo-500 via-violet-400 to-sky-400"
          }`}
        />

        <button
          type="button"
          onClick={onCancel}
          disabled={pending}
          aria-label="Close dialog"
          className="absolute right-3.5 top-3.5 grid h-8 w-8 place-items-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:opacity-40 dark:text-neutral-500 dark:hover:bg-white/5 dark:hover:text-neutral-200 dark:focus-visible:ring-offset-neutral-900"
        >
          <CloseIcon />
        </button>

        <div className="p-6 sm:p-7">
          <div className="relative inline-grid place-items-center">
            {isDanger ? (
              <span
                aria-hidden="true"
                className="cnfrm-ring absolute inset-0 rounded-2xl bg-rose-400/40"
              />
            ) : null}
            <span
              className={`relative grid h-12 w-12 place-items-center rounded-2xl border ${
                isDanger
                  ? "border-rose-200 bg-rose-50 text-rose-600 dark:border-rose-500/25 dark:bg-rose-500/10 dark:text-rose-300"
                  : "border-indigo-200 bg-indigo-50 text-indigo-600 dark:border-indigo-500/25 dark:bg-indigo-500/10 dark:text-indigo-300"
              }`}
            >
              {isDanger ? <WarningIcon /> : <RocketIcon />}
            </span>
          </div>

          <h2
            id={titleId}
            className="mt-4 text-lg font-semibold tracking-tight text-slate-900 dark:text-white"
          >
            {action.dialogTitle}
          </h2>
          <p
            id={descId}
            className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-neutral-400"
          >
            {action.dialogDescription}
          </p>

          {needsPhrase ? (
            <div className="mt-5">
              <label
                htmlFor={inputId}
                className="block text-sm font-medium text-slate-700 dark:text-neutral-300"
              >
                Type{" "}
                <code className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.8125rem] text-rose-600 dark:bg-white/10 dark:text-rose-300">
                  {action.confirmPhrase}
                </code>{" "}
                to confirm
              </label>
              <input
                ref={inputRef}
                id={inputId}
                type="text"
                autoComplete="off"
                spellCheck={false}
                value={typed}
                aria-describedby={hintId}
                aria-invalid={typed.length > 0 && !phraseOk}
                onChange={(event) => onTypedChange(event.target.value)}
                className="mt-2 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 focus-visible:border-rose-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/70 dark:border-white/15 dark:bg-neutral-950 dark:text-neutral-100 dark:placeholder:text-neutral-600"
                placeholder={action.confirmPhrase}
              />
              <p
                id={hintId}
                className="mt-1.5 h-4 text-xs text-slate-500 dark:text-neutral-500"
              >
                {typed.length > 0 && !phraseOk
                  ? "The text doesn’t match yet."
                  : " "}
              </p>
            </div>
          ) : null}

          <div className="mt-6 flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
            <button
              ref={cancelRef}
              type="button"
              onClick={onCancel}
              disabled={pending}
              className="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:opacity-50 dark:border-white/15 dark:bg-transparent dark:text-neutral-200 dark:hover:bg-white/5 dark:focus-visible:ring-offset-neutral-900"
            >
              {action.cancelLabel}
            </button>
            <button
              type="button"
              onClick={onConfirm}
              disabled={confirmDisabled}
              aria-disabled={confirmDisabled}
              className={`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold shadow-lg transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none dark:focus-visible:ring-offset-neutral-900 ${confirmClasses}`}
            >
              {pending ? <Spinner /> : null}
              {pending ? "Working…" : action.confirmLabel}
            </button>
          </div>
        </div>
      </motion.div>
    </motion.div>
  );
}

interface StatusState {
  message: string;
}

export default function ModalConfirm() {
  const reduce = useReducedMotion();
  const [active, setActive] = useState<DangerAction | null>(null);
  const [typed, setTyped] = useState("");
  const [pending, setPending] = useState(false);
  const [status, setStatus] = useState<StatusState | null>(null);

  const lastFocused = useRef<HTMLElement | null>(null);
  const timeoutRef = useRef<number | null>(null);

  useEffect(
    () => () => {
      if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
    },
    [],
  );

  const openDialog = useCallback(
    (actionItem: DangerAction, event: ReactMouseEvent<HTMLButtonElement>) => {
      lastFocused.current = event.currentTarget;
      setTyped("");
      setPending(false);
      setStatus(null);
      setActive(actionItem);
    },
    [],
  );

  const restoreFocus = useCallback(() => {
    requestAnimationFrame(() => lastFocused.current?.focus());
  }, []);

  const handleCancel = useCallback(() => {
    if (pending) return;
    setActive(null);
    restoreFocus();
  }, [pending, restoreFocus]);

  const handleConfirm = useCallback(() => {
    if (!active) return;
    const message = active.successMessage;
    setPending(true);
    timeoutRef.current = window.setTimeout(() => {
      setPending(false);
      setActive(null);
      setStatus({ message });
      restoreFocus();
    }, 900);
  }, [active, restoreFocus]);

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:bg-neutral-950 dark:text-white">
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 [background-image:radial-gradient(circle_at_1px_1px,rgba(148,163,184,0.18)_1px,transparent_0)] [background-size:22px_22px] dark:[background-image:radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.06)_1px,transparent_0)]"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 right-[-10%] h-72 w-72 rounded-full bg-rose-300/20 blur-3xl dark:bg-rose-600/10"
      />

      <div className="relative mx-auto max-w-2xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-wider text-slate-500 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-neutral-400">
            <span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
            Account settings
          </span>
          <h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            Danger zone
          </h1>
          <p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-neutral-400">
            Irreversible and high-impact actions live here. Each one asks for an
            explicit confirmation before anything happens.
          </p>
        </header>

        <div aria-live="polite" className="min-h-[3.5rem]">
          <AnimatePresence>
            {status ? (
              <motion.div
                key={status.message}
                role="status"
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                transition={{ duration: reduce ? 0 : 0.2 }}
                className="mb-6 flex items-start gap-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 dark:border-emerald-500/25 dark:bg-emerald-500/10"
              >
                <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-emerald-500 text-white">
                  <CheckIcon />
                </span>
                <p className="flex-1 text-sm text-emerald-800 dark:text-emerald-200">
                  {status.message}
                </p>
                <button
                  type="button"
                  onClick={() => setStatus(null)}
                  aria-label="Dismiss notification"
                  className="grid h-6 w-6 place-items-center rounded-md text-emerald-600 transition-colors hover:bg-emerald-500/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 dark:text-emerald-300"
                >
                  <CloseIcon />
                </button>
              </motion.div>
            ) : null}
          </AnimatePresence>
        </div>

        <div className="divide-y divide-slate-200 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:divide-white/10 dark:border-white/10 dark:bg-neutral-900">
          {ACTIONS.map((item) => {
            const danger = item.tone === "danger";
            return (
              <div
                key={item.id}
                className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:p-6"
              >
                <div className="min-w-0">
                  <h3 className="text-sm font-semibold text-slate-900 dark:text-white">
                    {item.rowTitle}
                  </h3>
                  <p className="mt-1 text-sm leading-relaxed text-slate-500 dark:text-neutral-400">
                    {item.rowDescription}
                  </p>
                </div>
                <button
                  type="button"
                  onClick={(event) => openDialog(item, event)}
                  className={`shrink-0 self-start rounded-lg border px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:self-auto dark:focus-visible:ring-offset-neutral-900 ${
                    danger
                      ? "border-rose-200 text-rose-700 hover:bg-rose-50 focus-visible:ring-rose-500 dark:border-rose-500/30 dark:text-rose-300 dark:hover:bg-rose-500/10"
                      : "border-indigo-200 text-indigo-700 hover:bg-indigo-50 focus-visible:ring-indigo-500 dark:border-indigo-500/30 dark:text-indigo-300 dark:hover:bg-indigo-500/10"
                  }`}
                >
                  {item.rowButton}
                </button>
              </div>
            );
          })}
        </div>

        <p className="mt-4 text-xs text-slate-400 dark:text-neutral-600">
          Tip: press{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-sans text-[0.6875rem] text-slate-600 dark:border-white/15 dark:bg-neutral-800 dark:text-neutral-300">
            Esc
          </kbd>{" "}
          to dismiss a dialog. Focus stays trapped inside until you decide.
        </p>
      </div>

      <AnimatePresence>
        {active ? (
          <ConfirmDialog
            key={active.id}
            action={active}
            typed={typed}
            pending={pending}
            onTypedChange={setTyped}
            onCancel={handleCancel}
            onConfirm={handleConfirm}
          />
        ) : null}
      </AnimatePresence>

      <style>{`
        @keyframes cnfrm-spin {
          to { transform: rotate(360deg); }
        }
        @keyframes cnfrm-ring {
          0%, 100% { opacity: 0.5; transform: scale(1); }
          50% { opacity: 0.1; transform: scale(1.4); }
        }
        .cnfrm-spin { animation: cnfrm-spin 0.7s linear infinite; }
        .cnfrm-ring { animation: cnfrm-ring 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cnfrm-spin, .cnfrm-ring { animation: none !important; }
        }
      `}</style>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →