Popconfirm
Overlays

Popconfirm

Inline destructive confirmation in an origin-aware popover — focus lands on Cancel, with optional hold-to-confirm and a linear fill that retreats on release.

Install

npx shadcn@latest add @paragon/popconfirm

Also installs: button, popover

popconfirm.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/paragon/ui/popover";

export interface PopconfirmProps {
  /** The element that opens the confirm — rendered asChild. */
  children: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  confirmLabel?: string;
  cancelLabel?: string;
  /** Runs when the destructive action is confirmed. */
  onConfirm?: () => void;
  onCancel?: () => void;
  /**
   * Require press-and-hold on the confirm button. A linear fill sweeps the
   * button while held; releasing early cancels.
   */
  holdToConfirm?: boolean;
  /** Hold time in ms before the action commits. */
  holdDuration?: number;
  /** Override the danger icon. Pass `null` to hide it. */
  icon?: React.ReactNode;
  side?: React.ComponentProps<typeof PopoverContent>["side"];
  align?: React.ComponentProps<typeof PopoverContent>["align"];
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
}

/**
 * Inline confirmation for destructive triggers: an origin-aware popover with
 * the danger context right next to the control, instead of a page-level
 * modal. Initial focus deliberately lands on Cancel — the safe path — so a
 * stray Enter never destroys anything. Optional hold-to-confirm arms the
 * primary button with a linear progress fill (releasing early cancels; the
 * fill retreats with an ease-out so the interruption reads as intentional).
 */
export function Popconfirm({
  children,
  title = "Are you sure?",
  description,
  confirmLabel = "Delete",
  cancelLabel = "Cancel",
  onConfirm,
  onCancel,
  holdToConfirm = false,
  holdDuration = 800,
  icon,
  side = "bottom",
  align = "center",
  open: openProp,
  defaultOpen,
  onOpenChange,
}: PopconfirmProps) {
  const reducedMotion = useReducedMotion();
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
    defaultOpen ?? false,
  );
  const open = openProp ?? uncontrolledOpen;
  const setOpen = React.useCallback(
    (next: boolean) => {
      setUncontrolledOpen(next);
      onOpenChange?.(next);
    },
    [onOpenChange],
  );

  const cancelRef = React.useRef<HTMLButtonElement>(null);
  const [holding, setHolding] = React.useState(false);
  const holdTimer = React.useRef<ReturnType<typeof setTimeout>>(null);

  const cancelHold = React.useCallback(() => {
    if (holdTimer.current) clearTimeout(holdTimer.current);
    holdTimer.current = null;
    setHolding(false);
  }, []);

  const commit = React.useCallback(() => {
    cancelHold();
    setOpen(false);
    onConfirm?.();
  }, [cancelHold, setOpen, onConfirm]);

  const startHold = React.useCallback(() => {
    if (holdTimer.current) return;
    setHolding(true);
    holdTimer.current = setTimeout(commit, holdDuration);
  }, [commit, holdDuration]);

  // A hold must never survive the tab going hidden or the popover closing.
  React.useEffect(() => {
    if (!open) cancelHold();
  }, [open, cancelHold]);
  React.useEffect(() => {
    const onHidden = () => {
      if (document.hidden) cancelHold();
    };
    document.addEventListener("visibilitychange", onHidden);
    return () => {
      document.removeEventListener("visibilitychange", onHidden);
      if (holdTimer.current) clearTimeout(holdTimer.current);
    };
  }, [cancelHold]);

  const holdHandlers: React.ComponentProps<"button"> = holdToConfirm
    ? {
        onPointerDown: startHold,
        onPointerUp: cancelHold,
        onPointerLeave: cancelHold,
        onPointerCancel: cancelHold,
        onKeyDown: (event) => {
          if ((event.key === "Enter" || event.key === " ") && !event.repeat) {
            event.preventDefault();
            startHold();
          }
        },
        onKeyUp: cancelHold,
        onBlur: cancelHold,
      }
    : { onClick: commit };

  // The linear sweep is progress (the one sanctioned linear); the retreat is
  // an interruption response, so it snaps back with ease-out. Reduced motion
  // trades the movement for a same-duration opacity fill.
  const fillStyle: React.CSSProperties = reducedMotion
    ? {
        transform: "scaleX(1)",
        opacity: holding ? 1 : 0,
        transitionProperty: "opacity",
        transitionDuration: holding ? `${holdDuration}ms` : "150ms",
        transitionTimingFunction: holding ? "linear" : "var(--ease-out)",
      }
    : {
        transform: holding ? "scaleX(1)" : "scaleX(0)",
        transitionProperty: "transform",
        transitionDuration: holding ? `${holdDuration}ms` : "150ms",
        transitionTimingFunction: holding ? "linear" : "var(--ease-out)",
      };

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>{children}</PopoverTrigger>
      <PopoverContent
        side={side}
        align={align}
        className="w-72 p-4"
        onOpenAutoFocus={(event) => {
          event.preventDefault();
          cancelRef.current?.focus();
        }}
      >
        <div className="flex items-start gap-3">
          {icon !== null && (
            <span
              aria-hidden
              className="flex size-8 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"
            >
              {icon ?? <TriangleAlert className="size-4" />}
            </span>
          )}
          <div className="min-w-0 flex-1 pt-0.5">
            <p className="text-sm leading-5 font-medium">{title}</p>
            {description && (
              <p className="mt-1 text-[13px] leading-5 text-muted-foreground">
                {description}
              </p>
            )}
          </div>
        </div>
        <div className="mt-4 flex justify-end gap-2">
          <Button
            ref={cancelRef}
            variant="outline"
            size="sm"
            onClick={() => {
              setOpen(false);
              onCancel?.();
            }}
          >
            {cancelLabel}
          </Button>
          <Button
            variant="destructive"
            size="sm"
            className="relative overflow-hidden"
            aria-live={holdToConfirm ? "polite" : undefined}
            {...holdHandlers}
          >
            {holdToConfirm && (
              <span
                aria-hidden
                className="pointer-events-none absolute inset-0 origin-left bg-destructive-foreground/25"
                style={fillStyle}
              />
            )}
            <span className="relative">
              {holding ? "Hold…" : confirmLabel}
            </span>
          </Button>
        </div>
      </PopoverContent>
    </Popover>
  );
}