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.
primitiv add confirm-dialog installs it whichever mode you are reading.Playground
Preview
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-dialogpnpm dlx primitiv add confirm-dialogyarn dlx primitiv add confirm-dialogbunx primitiv add confirm-dialogImport
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.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | The modal's sub-components (Trigger, Portal, Overlay, Content, ...). |
| defaultOpen | boolean | false | Whether 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. |
| open | boolean | — | Forbidden in uncontrolled mode — use defaultOpen instead.
The current open state. Must be kept in sync by the parent via onOpenChange. |
| ref | Ref<ModalImperativeApi> | — | Ref receiving the imperative ModalImperativeApiopen/close. |
ConfirmDialogTrigger
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | Description |
|---|---|---|---|
| asChild | boolean | false | Render 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.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | The dialog's body — the confirmation message, or any richer content.
Rendered inside ModalBody`ModalBody`'s own slot. |
| onConfirm | () => 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 | 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. |
| cancelLabel | string | "Cancel" | Cancel button label. The Cancel button always closes the dialog (it is
wrapped in ModalClose asChild) — there is no separate onCancel prop. |
| confirmLabel | string | "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. |
| ref | Ref<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). |
| showClose | boolean | false | Shows 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
| Key | Behaviour |
|---|---|
| Tab / Shift+Tab | Move between Cancel, Confirm and the optional close button, wrapping at each end. |
| Escape | Close the dialog and return focus to the trigger — the Cancel route, from the keyboard. |
| Enter / Space | Activate the focused button. onConfirm does not close the dialog on its own. |
Data attributes
ConfirmDialogContent
className: .primitiv-confirm-dialog
| Attribute | Value | When |
|---|---|---|
data-state | open | closed | open / closed |
Accessibility
- It is a real
<dialog>opened withshowModal(), inherited wholesale fromModal: the top-layer stacking, the inert background, focus trapping and Escape-to-close all come for free, not from az-indexand anaria-hiddensweep. - The
titleis the dialog's accessible name — it registers asaria-labelledby, and the body registers asaria-describedby, so both are announced on open. A confirmation with no title is announced as an unnamed dialog, sotitleis required. tonechanges 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) overOKso a screen-reader user hears the consequence.onConfirmdoes 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
showClosebutton is icon-only and carries its ownaria-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.
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.
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.
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.
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.
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>