Skip to content
Primitiv home
Framework
Consumption mode

Confirm Dialog

stableSource

A pre-arranged Modal for confirming or cancelling a single action — title, body slot, and a Cancel/Confirm footer, with the Confirm button's tone following the action (primary, or danger for a destructive one). Composes the registry modal and button components directly rather than introducing new dialog anatomy.

Playground

Density

Preview

Tone
Size
import { ConfirmDialog, ConfirmDialogTrigger, ConfirmDialogContent } from "@/components/ui/confirm-dialog";import { ModalPortal } from "@/components/ui/modal";import { Button } from "@/components/ui/button";
const [open, setOpen] = useState(false);
<ConfirmDialog open={open} onOpenChange={setOpen}>  <ConfirmDialogTrigger asChild>    <Button variant="primary">Publish release</Button>  </ConfirmDialogTrigger>  <ModalPortal forceMount>    <ConfirmDialogContent      title="Publish release?"      tone="default"      size="sm"      confirmLabel="Publish"      onConfirm={() => setOpen(false)}    >      This makes v2.1 available to everyone.    </ConfirmDialogContent>  </ModalPortal></ConfirmDialog>

Density is set by a data-density ancestor — the Context system, not a ConfirmDialog prop.

Installation

npx primitiv add confirm-dialog

Import

import { ConfirmDialog } from "@/components/ui/confirm-dialog";

No headless primitive — this one ships only as a copied styled surface, so primitiv add is the only way in, whichever mode you are reading.

Anatomy

<ConfirmDialog>  <ConfirmDialogTrigger />  <ModalPortal>    <ModalOverlay />        {/* optional */}    <ConfirmDialogContent />  </ModalPortal></ConfirmDialog>

Props

ConfirmDialog

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
childrenReactNode—The modal's sub-components (Trigger, Portal, Overlay, Content, ...).
defaultOpenbooleanfalseWhether the modal is open on first render. The component owns the flag from then on. Omit for closed-on-mount. Forbidden in controlled mode — use open instead.
onOpenChange(open: boolean) => void—Called with the new open state whenever it changes (open or close). Optional in uncontrolled mode — useful purely to observe transitions. Called with the requested open state whenever the modal wants to open or close (trigger click, Close button, Esc, click-outside, imperative API). Required in controlled mode.
openboolean—Forbidden in uncontrolled mode — use defaultOpen instead. The current open state. Must be kept in sync by the parent via onOpenChange.
refRef<ModalImperativeApi>—Ref receiving the imperative ModalImperativeApiopen/close.

ConfirmDialogTrigger

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
asChildbooleanfalseRender into the consumer's own element (e.g. a router <Link>) instead of the default <button>, merging the trigger's ARIA wiring, composed event handlers, and ref onto it via the Slot pattern.

ConfirmDialogContent

Extends HTMLDialogElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
children (required)ReactNode—The dialog's body — the confirmation message, or any richer content. Rendered inside ModalBody`ModalBody`'s own slot.
onConfirm (required)() => void—Called when the Confirm button is activated. Does not close the dialog itself — drive open/onOpenChange (or the imperative close() API) from this callback if confirming should dismiss it.
title (required)string & ReactElement<unknown, string | JSXElementConstructor<any>> | string & Iterable<ReactNode> | string & ReactPortal | string & Promise<AwaitedReactNode> | undefined—The dialog's title, shown in the header next to the optional close button.
cancelLabelstring"Cancel"Cancel button label. The Cancel button always closes the dialog (it is wrapped in ModalClose asChild) — there is no separate onCancel prop.
confirmLabelstring"Confirm"Confirm button label.
onEscapeKeyDown(event: Event) => void—Fires on the dialog's native cancel event when the user presses Escape. Call event.preventDefault() to veto closing and keep the modal open (e.g. to confirm unsaved changes first).
onPointerDownOutside(event: PointerEvent) => void—Fires on a pointerdown whose coordinates land outside the dialog's bounding rect — i.e. on the browser-painted ::backdrop. Call event.preventDefault() to veto closing and keep the modal open.
refRef<HTMLDialogElement>—Allows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref).
showClosebooleanfalseShows a close (×) button in the header, in addition to the Cancel/ Confirm footer actions. Off by default — the exploration found the footer's Cancel action already covers the "back out" affordance for a short confirmation, and a redundant close reads as noise.
size"sm" | "md" | "lg" | "xl""sm"Dialog width, forwarded to ModalContent's own size; data-density scales the padding within each size. Defaults smaller than a plain Modal — a confirmation is a short, low-content surface.
tone"default" | "danger""default"Selects the Confirm button's variant — default → primary, danger → danger — so the action's own tone drives the dialog, not a separate colour scheme for the dialog itself.

Styling contract

Keyboard

KeyBehaviour
Tab / Shift+TabMove between Cancel, Confirm and the optional close button, wrapping at each end.
EscapeClose the dialog and return focus to the trigger — the Cancel route, from the keyboard.
Enter / SpaceActivate the focused button. onConfirm does not close the dialog on its own.

Data attributes

ConfirmDialogContent

className: .primitiv-confirm-dialog

AttributeValueWhen
data-stateopen | closedopen / closed

Accessibility

  • It is a real <dialog> opened with showModal(), inherited wholesale from Modal: the top-layer stacking, the inert background, focus trapping and Escape-to-close all come for free, not from a z-index and an aria-hidden sweep.
  • The title is the dialog's accessible name — it registers as aria-labelledby, and the body registers as aria-describedby, so both are announced on open. A confirmation with no title is announced as an unnamed dialog, so title is required.
  • tone changes only the Confirm button's colour, never its accessible name. Colour is not a label, so the button text has to carry the meaning: prefer the verb (Remove, Delete) over OK so a screen-reader user hears the consequence.
  • onConfirm does not close the dialog — that is deliberate, so you can await an async action first — but it means you own dismissal. Close it from the callback (setOpen(false)), or the reader is left in a dialog that looks like it did nothing.
  • Focus returns to the trigger on close, every route out included (Cancel, Confirm-that-closes, Escape, the backdrop). If the trigger is gone by then — a row this dialog deleted — move focus somewhere deliberate yourself.
  • The optional showClose button is icon-only and carries its own aria-label="Close"; the footer's Cancel is the primary, always-present way out.

Examples

Confirming an action

The default shape: a title, a one-line body, and a primary Confirm beside Cancel. onConfirm fires when Confirm is pressed but does not close the dialog itself — drive open/onOpenChange from the callback (here setOpen(false)) so confirming dismisses it. Cancel always closes on its own; it is wrapped in ModalClose.

Density
import { ConfirmDialog, ConfirmDialogTrigger, ConfirmDialogContent } from "@/components/ui/confirm-dialog";import { ModalPortal } from "@/components/ui/modal";import { Button } from "@/components/ui/button";
const [open, setOpen] = useState(false);
<ConfirmDialog open={open} onOpenChange={setOpen}>  <ConfirmDialogTrigger asChild>    <Button>Publish release</Button>  </ConfirmDialogTrigger>  <ModalPortal forceMount>    <ConfirmDialogContent      title="Publish release?"      confirmLabel="Publish"      onConfirm={() => {        publish();        setOpen(false);      }}    >      This makes v2.1 available to everyone.    </ConfirmDialogContent>  </ModalPortal></ConfirmDialog>

Destructive actions

Pass tone="danger" for a destructive confirmation and the Confirm button turns danger — the action's own tone drives the dialog, so there is no separate colour scheme. Match the trigger's tone too, and label the button with the verb (Remove, Delete), not OK, so the consequence is legible before the click.

Density
import { ConfirmDialog, ConfirmDialogTrigger, ConfirmDialogContent } from "@/components/ui/confirm-dialog";import { ModalPortal } from "@/components/ui/modal";import { Button } from "@/components/ui/button";
const [open, setOpen] = useState(false);
<ConfirmDialog open={open} onOpenChange={setOpen}>  <ConfirmDialogTrigger asChild>    <Button variant="danger">Remove member</Button>  </ConfirmDialogTrigger>  <ModalPortal forceMount>    <ConfirmDialogContent      title="Remove member?"      tone="danger"      confirmLabel="Remove"      onConfirm={() => {        removeMember();        setOpen(false);      }}    >      This person will lose access immediately. This can't be undone.    </ConfirmDialogContent>  </ModalPortal></ConfirmDialog>

A richer body

children is a genuine ModalBody slot, not a message string — so the body can carry more than a sentence when the decision needs it. Keep it short: a confirmation is a fork in the road, not a form.

Density
import { ConfirmDialog, ConfirmDialogTrigger, ConfirmDialogContent } from "@/components/ui/confirm-dialog";import { ModalPortal } from "@/components/ui/modal";import { Button } from "@/components/ui/button";
const [open, setOpen] = useState(false);
<ConfirmDialog open={open} onOpenChange={setOpen}>  <ConfirmDialogTrigger asChild>    <Button variant="danger">Delete project</Button>  </ConfirmDialogTrigger>  <ModalPortal forceMount>    <ConfirmDialogContent      title='Delete "Harmoni"?'      tone="danger"      confirmLabel="Delete project"      onConfirm={() => {        deleteProject();        setOpen(false);      }}    >      <p>Deleting a project removes:</p>      <ul>        <li>all 42 palettes and their history</li>        <li>every share link and export</li>      </ul>    </ConfirmDialogContent>  </ModalPortal></ConfirmDialog>

Sizes and density

size forwards to ModalContent and defaults to sm — a confirmation is a short, low-content surface. Reach for a larger size only when the body genuinely needs the room, and each size rescales again with the nearest data-density ancestor. ModalPortal defaults to document.body, so pass container to keep a dialog under a data-density set on a section rather than the document — which is what these previews do.

Density
import { ConfirmDialog, ConfirmDialogTrigger, ConfirmDialogContent } from "@/components/ui/confirm-dialog";import { ModalPortal } from "@/components/ui/modal";
<div data-density="comfortable">  <ConfirmDialogContent size="sm" title="…" onConfirm={…}>…</ConfirmDialogContent>  <ConfirmDialogContent size="md" title="…" onConfirm={…}>…</ConfirmDialogContent>  <ConfirmDialogContent size="lg" title="…" onConfirm={…}>…</ConfirmDialogContent>  <ConfirmDialogContent size="xl" title="…" onConfirm={…}>…</ConfirmDialogContent></div>

A header close button

showClose adds a × button in the header, in addition to the footer's Cancel. It is off by default — the exploration found the footer's Cancel already covers the "back out" affordance for a short confirmation, so a second close reads as noise. Turn it on for a longer or richer surface where the escape hatch at the top-right is worth the extra target.

Density
import { ConfirmDialog, ConfirmDialogTrigger, ConfirmDialogContent } from "@/components/ui/confirm-dialog";import { ModalPortal } from "@/components/ui/modal";import { Button } from "@/components/ui/button";
<ConfirmDialog>  <ConfirmDialogTrigger asChild>    <Button>Leave feedback</Button>  </ConfirmDialogTrigger>  <ModalPortal forceMount>    <ConfirmDialogContent showClose title="Send feedback?" onConfirm={send}>      Your note goes to the team — thank you.    </ConfirmDialogContent>  </ModalPortal></ConfirmDialog>