Overlays

ConfirmDialog

Asks the user to confirm an action, with Cancel focused first. It is always a centered dialog, on phones too, and an outside press does not dismiss it. The actions stack full width on phones, the confirm action at the bottom, nearest the thumb.

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

In its source: app/components/recipe/StepList.tsx · app/routes/cookbooks.$id.tsx · app/routes/moderation.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/confirm-dialog#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/confirm-dialog

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/confirm-dialog#v1.2.13 ourostack/ouro-ui/confirm-dialog-tests#v1.2.13
import { ConfirmDialog } from "@/components/ouro/dialog/confirm-dialog";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<ConfirmDialog
  title="Delete recipe?"
  description="This can't be undone."
  confirmLabel="Delete"
  tone="destructive"
  onConfirm={() => deleteRecipe(id)}
  trigger={<Button tone="destructive" variant="soft">Delete</Button>}
/>

Usage

Use tone="destructive" for destructive actions, and return a promise from onConfirm to show progress until the action finishes. Cancel and Escape close the dialog even while the action runs, and its result is then ignored. Cancel does not stop an action that has already started; make onConfirm cancellable or idempotent if that matters.

It stacks above the page at --ouro-layer, a z-index of 50 by default (from ouro-base.css). If your app has fixed bars above z-50, raise it in a :root rule of your own after the Ouro tokens, outside any @layer: :root { --ouro-layer: 60; }. Don't edit the --ouro-layer: 50 line shadcn wrote; shadcn add resets it.

Testing accessibility: in WebKit, Base UI's focus guards take role="button" without a name so VoiceOver's cursor stays trapped. Exclude them: new AxeBuilder({ page }).exclude("[data-base-ui-focus-guard]").

Use instead when

  • Use Dialog instead when the user has more to do than confirm or cancel.

Props

The props the modes share. Type: ConfirmDialogProps.

cancelLabel
Type: string
Default: "Cancel"
The cancel button's label. Cancel has focus when the dialog opens.
confirmLabel (required)
Type: string
The confirm button's label: the action, not "OK" (confirmLabel="Delete").
description
Type: ReactNode
What happens if the user confirms: description="This can't be undone.".
finalFocus
Type: RefObject<HTMLElement | null>
Where focus goes after a confirmed action closes the confirmation. Any other close (Cancel, Escape, or your app setting open to false) returns focus to the trigger, even after an earlier confirm. When onConfirm removes the trigger, point this at what replaces it (give a heading tabIndex={-1}): finalFocus={headingRef}.
onConfirm (required)
Type: () => void | Promise<void>
Runs the action. Return a promise to keep the dialog open while it runs: the confirm button shows loading until it settles. When it resolves the dialog closes; when it rejects (or onConfirm throws) the dialog stays open so the user can retry or cancel. Cancel and Escape still close it while pending, so a stuck action never traps the user, and the result is then ignored. Cancel does not stop an action that has already started; make onConfirm cancellable or idempotent if that matters. If the action removes the trigger (a delete), pass finalFocus to what replaces it, such as the next item or the page heading.
title (required)
Type: string
The question, which names the dialog: title="Delete recipe?".
tone
Type: "primary" | "destructive"
Default: "primary"
The confirm button's color role. Use destructive for destructive actions.

Modes

ConfirmDialogProps is a union: pass the props of exactly one mode. Mixing them is a type error.

Controlled: ConfirmDialogControlledProps

A controlled ConfirmDialog: you own open and update it in onOpenChange.

onOpenChange (required)
Type: (open: boolean) => void
Called with false when the user cancels (Cancel or Escape) and when onConfirm finishes.
open (required)
Type: boolean
Whether the confirmation is open.

Not in this mode (typed never):

  • trigger: Not with open. Remove open and onOpenChange to let a trigger open the confirmation.

With trigger: ConfirmDialogWithTriggerProps

An uncontrolled ConfirmDialog: trigger opens it.

trigger (required)
Type: ReactElement
The element that opens the confirmation, usually a Button: trigger={<Button tone="destructive">Delete</Button>}.

Not in this mode (typed never):

  • onOpenChange: Not with trigger. Remove trigger to control the confirmation with open and onOpenChange.
  • open: Not with trigger. Remove trigger to control the confirmation with open and onOpenChange.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • cancelText: Not a prop. Ouro UI uses cancelLabel: <ConfirmDialog cancelLabel="Keep">.
  • confirmText: Not a prop. Ouro UI uses confirmLabel: <ConfirmDialog confirmLabel="Delete">.
  • destructive: Not a prop. Ouro UI uses tone: <ConfirmDialog tone="destructive">.
  • isOpen: Not a prop. Ouro UI uses open with onOpenChange: <ConfirmDialog open={open} onOpenChange={setOpen}>.
  • onCancel: Not a prop. Cancel calls onOpenChange(false): <ConfirmDialog open={open} onOpenChange={setOpen}>.
  • onClose: Not a prop. onOpenChange(false) fires on close: <ConfirmDialog open={open} onOpenChange={setOpen}>.
  • variant: Not a prop. Ouro UI uses tone: <ConfirmDialog tone="destructive">.