Composites

Confirm Dialog

One way to ask “are you sure?” across every Comitor app. ConfirmDialog wraps the AlertDialog primitive and adds the part everyone re-implements badly: an async confirm that keeps the dialog open, disabled and honest while the request is in flight.

Basic usage

Hand it a trigger and it manages its own open state. The title should be the question and the confirm label should repeat the verb — never OK, which reads the same for “archive” and “delete forever”.

No action taken yet.

Destructive tone and the icon slot

variant="destructive" turns the confirm button red and tints the icon tile. icon is an IconComponent — pass the component itself, icon={Trash2}, never icon={<Trash2 />}. A Lucide icon is a forwardRef object, so nothing at runtime can tell a component from an already-rendered element; the type is narrow on purpose so the compiler catches it instead of the browser.

Invoice INV-2048 is live.

The third dialog above keeps the default tone: signing out is disruptive but reversible, so it does not deserve red. Reserve destructive for actions that destroy data or revoke access, or it stops meaning anything.

Controlled vs uncontrolled

The component reads its state as open ?? internalOpen. Omit open and it keeps its own; pass it — even once — and the internal state is never written again, so a missing onOpenChange means a dialog that opens and never closes. Controlled mode is what you need when the opener is a menu item, a table row action, or a keyboard shortcut rather than a button sitting next to the dialog.

open = false · Trần Minh has access.

onOpenChange fires in both modes, so an uncontrolled dialog can still tell you when it closed — useful for resetting the fields you put in children.

Async confirm

This is the reason the composite exists. Return a promise from onConfirm and four things happen at once, none of which you have to wire:

  • The confirm button disables itself and swaps its label for a pending one.
  • Cancel disables too, and the dialog refuses every close while pending — Esc included. The guard is literally if (pending && !next) return.
  • Radix’s own auto-close on the action button is suppressed with event.preventDefault(), so the dialog closes only after the promise resolves. Closing first and calling the API afterwards is the surest way to collect a double delete.
  • A rejection leaves the dialog open so the reader can retry, and the error is handed to onError. It is deliberately not re-thrown: the internal call is void handleConfirm(), so re-throwing would produce an unhandled rejection nothing in your app could catch.

Waiting.

Extra content and gated confirmation

children render between the header and the footer. Pair them with confirmDisabled for the highest-stakes deletions: the reader has to type the record’s name before the red button unlocks.

atlas-migration exists.

Writing it in English

Unlike DataTable or TablePagination, this composite has no labels bag — three plain string props, all defaulting to Vietnamese ("Xác nhận", "Hủy" and "Đang xử lý…"). Pass them every time.

tsx
import { ConfirmDialog, Button } from '@comitor/ui'

// The package ships Vietnamese defaults: confirmLabel "Xác nhận", cancelLabel "Hủy".
// They are plain string props, not a `labels` bag — pass both on every dialog in an
// English app, and prefer a verb that repeats the action over a bare "OK".
export function Example({ publish }: { publish: () => void }) {
  return (
    <ConfirmDialog
      trigger={<Button>Publish</Button>}
      title="Publish this release?"
      description="Everyone on the workspace gets the new version on their next reload."
      confirmLabel="Publish release"
      cancelLabel="Not yet"
      onConfirm={publish}
    />
  )
}

// ⚠ A THIRD string: while `onConfirm` is pending the button reads "Đang xử lý…" unless you
// pass `pendingLabel`. Set it on every dialog whose onConfirm returns a promise.

A third string hides behind the async path: while an onConfirm promise is in flight the confirm button swaps to pendingLabel, which defaults to "Đang xử lý…". It only shows for the second or two the request takes, which is exactly why it gets forgotten — pass it on every dialog whose onConfirm returns a promise.

Props

PropTypeDefaultDescription
titlerequiredReactNodeDialog heading. Rendered as AlertDialogTitle — Radix uses it as the accessible name of the dialog, so it is required.
onConfirmrequired() => void | Promise<void>Runs when the confirm button is pressed. Return a promise and the dialog holds itself open, disables both buttons and shows a pending label until it settles.
descriptionReactNodeSecondary line under the title, wired as AlertDialogDescription (aria-describedby).
childrenReactNodeExtra content between the header and the footer — a reason textarea, the list of records about to be affected, a type-to-confirm field.
iconIconComponentIcon COMPONENT (e.g. Trash2), not an element. Renders in a 36px rounded tile to the left of the title.
variant'default' | 'destructive''default'destructive paints the confirm button red and tints the icon tile with bg-destructive/15 + text-destructive-ink.
confirmLabelstring'Xác nhận'Text of the confirm button. Vietnamese by default — pass an English verb phrase.
cancelLabelstring'Hủy'Text of the cancel button. Vietnamese by default.
pendingLabelstring'Đang xử lý…'Confirm-button text while an async onConfirm is in flight. Vietnamese by default — pass it on every dialog whose onConfirm returns a promise.
confirmDisabledbooleanfalseLocks the confirm button — the gate for type-to-confirm flows and unmet preconditions.
openbooleanControlled open state. Omit it and the dialog keeps its own state (use with trigger); pass it and every close is routed through onOpenChange.
onOpenChange(open: boolean) => voidFires on every open/close attempt, in both controlled and uncontrolled mode. Called with false after a successful confirm.
triggerReactNodeElement that opens the dialog. Wrapped in AlertDialogTrigger asChild, so it must be a single element that forwards ref and props.
onError(error: unknown) => voidReceives anything onConfirm throws. Without it the error only reaches console.error — it is never re-thrown.
classNamestringClasses merged onto AlertDialogContent (the panel itself), not onto the overlay.

ConfirmDialogProps is a closed interface — it does not spread native div props onto the panel. Anything the list above does not cover means reaching for the AlertDialog parts directly.

Why the destructive icon is -ink

The icon tile follows the same recipe as statCardIconVariants: a tint of the role colour as the background, and the -ink step for the glyph on top.

  • destructive bg-destructive/15 with text-destructive-ink.
  • default bg-muted with text-muted-foreground.

In the default palette that choice looks like a no-op: --destructive-ink is declared as var(--destructive), the same #d64545 in both themes, so the glyph measures 4.06:1 in light and 3.14:1 in dark on the tint — over the 3:1 floor for non-text content, with no margin to spare. The point is the seam: under data-contrast="high" the ink token re-points at --red-ink and the identical markup measures 5.76:1 and 5.71:1. Writing text-destructive instead would freeze the first pair of numbers into both palettes — the whole reason the three-role contract splits x from xInk. Either token flips with the theme on its own, so the tile needs no dark: override, and it is aria-hidden regardless: it repeats what the title already says.

Accessibility

  • Built on Radix AlertDialog, which renders role="alertdialog" with aria-modal, traps focus inside the panel, and returns focus to the trigger on close. Unlike a plain dialog it cannot be dismissed by clicking the overlay — the reader has to answer the question.
  • title becomes the accessible name (aria-labelledby) and description the accessible description (aria-describedby). Write the title as a question that stands alone — a screen reader announces it the moment the dialog opens, before any of the surrounding text.
  • Radix gives initial focus to the cancel button, so the safe answer is the one under the reader’s finger. Tab moves between Cancel and Confirm; Esc cancels.
  • While an async onConfirm is pending, both buttons carry disabled and Esc is swallowed. This is a deliberate, brief trap: an in-flight destructive request that the reader can walk away from is worse than two seconds of a locked dialog.
  • The icon tile is aria-hidden="true" — it is decoration, so the message must survive without it. Never let red be the only thing that says “this deletes data”; say it in the title or the confirm label (WCAG 1.4.1).
  • confirmDisabled uses the real disabled attribute, which removes the button from the tab order. Always explain the gate in children — a button that is dark and unreachable with no visible reason is a dead end.
  • Content you place in children is inside the focus trap, so label every field properly; the reader will Tab through it before reaching the footer.