Skip to contentVibraUI
Feedback & status

Confirm dialog

A one-question alert dialog that awaits its confirm handler and can gate it behind a typed word.

A client component. Leave open unset and the trigger drives it; pass open and onOpenChange to own it, in which case only the owner can close it. onConfirm is awaited before the dialog closes, so the confirm button spins on the real work. confirmationWord is compared exactly, after trimming, and the field is cleared whenever the dialog closes so reopening never starts pre-confirmed. It has no className or rest props: the alert-dialog parts underneath carry the styling.

Install

npx shadcn@latest add @vibra/confirm-dialog

Needs the @vibra registry in your components.json — set it up once.

Examples

Destructive with a typed confirmation

The confirm button stays disabled until the workspace name is typed exactly.

Props

PropTypeDefaultDescription
titleReact.ReactNode—The question, phrased so that confirming is unambiguous.
descriptionReact.ReactNode—What confirming actually does, and whether it can be undone.
triggerReact.ReactNode—Rendered as the trigger itself, not wrapped in one, so pass an element.
openboolean—Controls the dialog; leave it off and the trigger opens and closes it on its own.
onOpenChange(open: boolean) => void—Called on every open and close, including the one after a confirm.
onConfirm() => void | Promise<void>—Awaited before the dialog closes; reject to keep the dialog open.
confirmTextstringConfirmLabel for the confirm button; name the action, not the agreement.
cancelTextstringCancelLabel for the cancel button.
variant"default" | "destructive""default"destructive turns the confirm button red.
confirmationWordstring—Gates confirm behind typing this exact word — for deletes that cannot be undone.
loadingbooleanfalseForces the confirm button's spinner, for work tracked outside the dialog.
childrenReact.ReactNode—Extra detail inside the dialog, below the description.
classNamestring—Merged onto the dialog content, which is the root; the remaining props are spread onto it too — the trigger is your own element.

Dependencies

Source

components/ui/confirm-dialog.tsx
"use client"

import * as React from "react"

import {
  AlertDialog,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { AsyncButton } from "@/components/ui/async-button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

// The dialog content is the root: className and the rest of the props land on
// it, not on the trigger, which is the caller's own element. `title` and
// `children` are re-declared because the item owns them — the heading and the
// extra detail under the description.
export type ConfirmDialogProps = Omit<
  React.ComponentProps<typeof AlertDialogContent>,
  "title" | "children" | "className" | "render" | "size"
> & {
  className?: string
  /** Controls the dialog. Leave it off and the trigger opens and closes it on its own. */
  open?: boolean
  onOpenChange?: (open: boolean) => void
  /** The element that opens the dialog — rendered as the trigger itself, not wrapped in one. */
  trigger?: React.ReactNode
  title: React.ReactNode
  description?: React.ReactNode
  confirmText?: string
  cancelText?: string
  variant?: "default" | "destructive"
  /** Gates confirm behind typing this exact word — for deletes that cannot be undone. */
  confirmationWord?: string
  /** Awaited before the dialog closes, so the confirm button spins until the work is done. */
  onConfirm: () => void | Promise<void>
  loading?: boolean
  /** Extra detail inside the dialog, below the description. */
  children?: React.ReactNode
}

function ConfirmDialog({
  className,
  open,
  onOpenChange,
  trigger,
  title,
  description,
  confirmText = "Confirm",
  cancelText = "Cancel",
  variant = "default",
  confirmationWord,
  onConfirm,
  loading = false,
  children,
  ...props
}: ConfirmDialogProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
  const [typed, setTyped] = React.useState("")
  const inputId = React.useId()

  const isOpen = open ?? uncontrolledOpen
  const gated = confirmationWord !== undefined && typed.trim() !== confirmationWord

  function setOpen(next: boolean) {
    // Re-arm the gate on the way out, so reopening never starts pre-confirmed.
    if (!next) setTyped("")
    setUncontrolledOpen(next)
    onOpenChange?.(next)
  }

  async function handleConfirm() {
    await onConfirm()
    setOpen(false)
  }

  return (
    <AlertDialog open={isOpen} onOpenChange={setOpen}>
      {trigger ? <AlertDialogTrigger render={trigger as React.ReactElement} /> : null}

      <AlertDialogContent
        data-slot="confirm-dialog"
        data-variant={variant}
        className={className}
        {...props}
      >
        <AlertDialogHeader>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          {description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
        </AlertDialogHeader>

        {children ? (
          <div data-slot="confirm-dialog-body" className="text-sm text-muted-foreground">
            {children}
          </div>
        ) : null}

        {confirmationWord !== undefined ? (
          <div data-slot="confirm-dialog-confirmation" className="flex flex-col gap-2 text-start">
            <Label htmlFor={inputId} className="text-xs font-normal text-muted-foreground">
              Type <span className="font-mono font-medium text-foreground">{confirmationWord}</span>{" "}
              to confirm
            </Label>
            <Input
              id={inputId}
              value={typed}
              onChange={(event) => setTyped(event.target.value)}
              autoComplete="off"
              autoCorrect="off"
              spellCheck={false}
              className="font-mono text-sm"
            />
          </div>
        ) : null}

        <AlertDialogFooter>
          <AlertDialogCancel>{cancelText}</AlertDialogCancel>
          <AsyncButton
            data-slot="confirm-dialog-confirm"
            variant={variant === "destructive" ? "destructive" : "default"}
            disabled={gated}
            loading={loading}
            onClick={handleConfirm}
          >
            {confirmText}
          </AsyncButton>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

export { ConfirmDialog }