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.
npx shadcn@latest add @paragon/popconfirmAlso installs: button, popover
"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>
);
}