Feedback
Popconfirm
A confirmation anchored to the control that raised it — non-modal, keyboard-complete, one-shot, with a pending state and an inline rejection that keeps the panel open.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/popconfirm.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Popconfirm" component on Radix's Popover
primitive (the `radix-ui` package) plus lucide-react icons and a Button
primitive. It asks one yes/no question anchored to the control that raised it
and never takes the screen away. Do not hand-roll the floating layer: Radix
already owns anchoring, collision flipping, the portal, the dismiss layer and
focus return.
Contract
- forwardRef<HTMLDivElement>, extending
Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onCancel" | "title">.
The ref and any spread props land on the PANEL (the portalled dialog), which
only exists while the question is open — say so in the JSDoc, because the
trigger is the visible part and consumers will assume otherwise.
- trigger: ReactNode — rendered inside Popover.Trigger with
asChild={React.isValidElement(trigger)}, so a real element keeps its own type,
styling and handlers while receiving aria-haspopup="dialog" / aria-expanded /
data-state, and a bare string still gets a working button. The trigger opens
the question; it must never perform the action.
- title: ReactNode (required), description?: ReactNode — both rendered into <p>,
so document "text and inline nodes, not blocks".
- confirmLabel?: string = "Confirm", cancelLabel?: string = "Cancel",
pendingLabel?: string = "Working…".
- destructive?: boolean = false — paints Confirm destructive AND moves initial
focus to Cancel.
- placement?: "top" | "right" | "bottom" | "left", each also with "-start" /
"-end" (12 values), default "top". Split it on the dash into Radix's
side + align; the missing half is align "center".
- icon?: ReactNode — defaults to a warning triangle when destructive and a
question mark otherwise; `null` renders no icon. Resolve with
`icon === undefined ? fallback : icon`, never `??`, or `null` can't turn it off.
- disabled?: boolean = false — the action is unavailable.
- open?: boolean + onOpenChange?: (open: boolean) => void — controlled when
`open` is passed, otherwise the component owns the state.
- onConfirm: () => void | Promise<unknown> (required),
onCancel?: () => void, className merged onto the panel via cn().
Behavior
- Non-modal by design (Popover.Root modal={false}): no scrim, no scroll lock,
the page underneath stays readable. An outside pointer press and Escape both
decline; the trigger toggles it closed again.
- Escape must not leak. Handle onKeyDown on the panel and stopPropagation() on
Escape after chaining the consumer's handler and bailing on defaultPrevented.
React portals bubble through the REACT tree, not the DOM tree, so without that
stop one keypress closes both this panel and the drawer, dialog or command
menu the trigger lives in. Radix has already dismissed this layer from its own
document listener by then, so nothing is lost.
- Tab is contained, in the same handler and for the same portal reason: forward
Tab on Confirm goes to Cancel, Shift+Tab on Cancel goes to Confirm, everything
else is native. The panel's DOM neighbour is the end of <body>, so tabbing out
would dismiss the question and strand a keyboard user at the bottom of the
document. This is containment, not modality — no scrim, no scroll lock,
nothing hidden from a screen reader, and Escape / Cancel / Confirm are three
ways out that all return focus to the trigger. Because there is no children
slot the panel always has exactly two focusables, which is what makes a
two-line cycle correct instead of a focus-trap re-implementation.
- Focus: onOpenAutoFocus, preventDefault and focus your own button —
Cancel when destructive, Confirm otherwise — via refs, with
focus({ preventScroll: true }). Radix's non-modal content returns focus to the
trigger on close, except after an outside interaction, where focus belongs to
wherever the reader just clicked.
- One-shot: a ref (not state) read AND written synchronously inside the confirm
handler. A double click, a held Enter and a click racing the keyboard all land
before any state update has rendered, and a state flag still reads false in
the second closure. Release it on every open/close transition (an effect keyed
on the open state) and on rejection — never while the panel stays open.
- onConfirm returning void closes the panel immediately: no phantom spinner for
a consumer that just splices an array. A thenable (duck-typed, not
`instanceof Promise`) switches the panel to pending: aria-busy, a spinning
loader in Confirm, pendingLabel, and EVERY dismissal refused through one guard
at the top of the open-change handler, plus onEscapeKeyDown preventDefault so
Radix does not act on the key either.
- Rejection keeps the panel open, renders the reason inline, announces it
politely and re-arms Confirm for a retry. Normalise the reason: Error.message,
a bare string, else a generic sentence — never "[object Object]". A handler
that throws synchronously takes the same path.
- Cancel, Escape, an outside press and focus leaving the panel all call
onCancel; a successful confirm does not.
- disabled refuses to open (guard the open-change handler) and refuses to run
the action. Both the trigger and Confirm express it with aria-disabled and a
handler guard, NEVER the native attribute: the browser blurs a node to <body>
the instant it is disabled, which would drop the focus of a reader standing on
the button that just went busy. Keep pointer-events on, so it stays hoverable,
focusable and announced.
- Every open starts clean — pending false, error null, live region empty — via a
render-phase state sync against the previous open value, not an effect, so the
panel is correct in the frame it appears.
Keyboard map
- Trigger: Enter / Space opens; Escape while focused there belongs to the page.
- On open focus is inside the panel: Cancel for destructive, Confirm otherwise.
- Tab / Shift+Tab move between Cancel and Confirm and cycle at the ends; focus
never falls out of the portal, so the question is always answered on purpose.
- Enter / Space activate the focused button, and auto-repeat activates nothing:
the Enter that opened the panel is still down when focus lands on a button, so
a repeat keydown is swallowed (preventDefault kills the implicit click) rather
than answering a question nobody has read yet. A deliberate second press works.
- Escape declines and returns focus to the trigger; while pending it is refused
and still does not reach any outer layer.
ARIA contract
- role="dialog" (Radix), aria-labelledby -> the title <p>, aria-describedby ->
the description <p> when there is one. Not alertdialog: that role promises the
reader must respond before continuing, and this surface is dismissible.
- aria-busy on the panel while pending.
- The inline failure is a plain <p> referenced by Confirm's aria-describedby —
no role="alert" — because a polite sr-only role="status" region inside the
panel already speaks it once, and twice is worse than never.
- Announce the pending label and the failure text through that region, clearing
it on a timer so an identical next message is announced again. Mount it empty
with the panel or the first change may never be seen as a change.
- Icons are aria-hidden; the trigger of an icon-only action needs its own name.
Cleanup
- Clear the announcement timer on unmount and before re-arming it.
- Guard every async settlement with a mounted ref that is re-armed in the effect
body, not just initialised — StrictMode mounts, unmounts and remounts.
- Radix owns the positioning listeners, the portal and the dismiss layer;
do not add your own scroll or resize handlers.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground panel with border,
shadow-md, rounded-lg; text-muted-foreground description; the failure line
bg-destructive/10 + text-destructive; the icon text-destructive when
destructive, text-muted-foreground otherwise. No hex, no oklch().
- Panel: w-64 max-w-[calc(100vw-2rem)], flex column, gap-3, collisionPadding 12,
sideOffset 8 — small enough to read as an annotation of the trigger rather
than a dialog.
- Enter/exit with data-[state=open]:animate-in fade-in-0 zoom-in-95 and the
closed mirror, plus motion-reduce:animate-none; the spinner is
animate-spin motion-reduce:animate-none. With motion off the panel still
appears, and pendingLabel plus aria-busy still report the busy state.
- Long titles, long descriptions and long rejection messages wrap
(wrap-anywhere) instead of being clipped — a rejection is usually a sentence
from a server.
- Merge the consumer className last through cn(); keep focus-visible rings on
both buttons.
Customization levers
- Placement is the layout lever: "left" / "right" for table row actions,
"top" for toolbars, "-start" / "-end" to keep the panel inside a narrow card.
Radix flips it automatically, so pick the intent and let collisions win.
- Width: w-64 is one lever; widen to w-72 for two-line descriptions, or drop the
description entirely for a bare "Delete?" and the panel shrinks to two rows.
- Swap which ref onOpenAutoFocus targets to change the safe default, or focus
the panel itself to have the question read before the options.
- Tone: destructive drives the icon, the Confirm variant and the focus target
together — for a merely irreversible-but-not-dangerous action keep it false
and change only the labels.
- Add a third action (e.g. "Don't ask again") by extending the footer row; keep
Cancel first in DOM order so Tab reaches the safe option first.
- Swap the Button primitive for your own: the component only needs a focusable
element that accepts a ref, aria-disabled and a click handler.Concepts
- Annotation, not interruption — the question is anchored to the control that raised it and leaves the page readable, which is what makes it right for one small action taken in place and wrong for the decision that deserves to stop everything.
- Escape stays local, Tab stays inside — a portalled panel bubbles through the React tree and its DOM neighbour is the end of
<body>, so Escape is stopped at the panel and Tab cycles between the two buttons; without the first, confirming inside a drawer costs you the drawer, and without the second a keyboard user tabs out of the question into the bottom of the page. - One-shot by ref, not by state — the lock is read and written in the same tick as the activation, so the second half of a double click and a click racing the keyboard both find it armed; a
pendingstate flag is stillfalsein that second closure, and auto-repeat is stopped one layer earlier, at the keydown. - Pending owns the panel — while a returned promise is unsettled, Escape, outside presses and Cancel are all refused by a single guard, so an in-flight delete can never lose the surface its failure has to land on.
- Failure lands where the decision was made — the rejection is rendered inline and Confirm re-arms, so a retry costs no navigation and no hunting for a toast that has already faded.
- aria-disabled over disabled — an unavailable action stays focusable and announced; the native attribute would blur the very button the reader is standing on to
<body>, which is how a keyboard user ends up back at the top of the page.
Conflict Resolver
A field-level three-way merge for concurrent edits — base/mine/theirs per field, only the genuinely conflicting ones asked about, bulk fill with per-field override, and a merged record on submit.
Pull To Refresh
A pull-down-to-refresh wrapper: a rubber-banded drag claimed only while the scroller is at the top, a threshold that arms the release, an async refresh held until it settles, and a keyboard-reachable refresh button.