component — overlays

Alert Dialog

The themed replacement for window.confirm(): a two-answer alertdialog surface, plus useConfirm() — a hook returning Promise<boolean>, so the line that asked the question still reads the answer.

  • confirm
  • alert
  • dialog
  • destructive
  • promise
  • overlay
  • agents

04 examples

Guarding a destructive action

useConfirm() returns Promise<boolean>, so the handler keeps its shape: the question and the work it guards stay on the same two lines.

No project deleted yet.

String shorthand

When the question is the whole of it, pass a string — the migration from window.confirm() is then an import, a provider, and an await.

Waiting.

Two questions in a row

A dialog is modal, so questions queue: the second opens when the first is answered, and each await resolves in turn.

Waiting.

Controlled, without the hook

AlertDialog on its own is the declarative shape — isOpen plus onConfirm/onCancel. Escape and the Cancel button are the same answer.

Waiting.

source

alert-dialog.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"
import { Button } from "@/components/button"
import {
  ModalBody,
  ModalContent,
  type ModalContentProps,
  ModalDescription,
  ModalFooter,
  ModalHeader,
  ModalTitle,
} from "@/components/modal"

/**
 * AlertDialog — quebi design system
 *
 * The library's answer to `window.confirm()`, in two shapes.
 *
 * - **`<AlertDialog>`** is the surface: a `role="alertdialog"` modal with a
 *   title, a consequence, and exactly two buttons. Controlled, like every other
 *   overlay here.
 *
 * - **`useConfirm()`** is the same surface reached imperatively. It returns a
 *   function that returns `Promise<boolean>`, so the line that asked the
 *   question is still the line that reads the answer:
 *
 *   ```tsx
 *   const confirm = useConfirm()
 *
 *   async function onDelete() {
 *     if (!(await confirm({ title: "Delete this project?", intent: "danger" }))) return
 *     await deleteProject(id)
 *   }
 *   ```
 *
 *   That is the whole point of the hook. `confirm()` is synchronous and a React
 *   dialog is not, so the usual replacement splits a handler in two and moves
 *   its second half onto a button. Awaiting a promise does not: the handler
 *   keeps its shape and only gains an `await`.
 *
 * `useConfirm()` throws unless a `<ConfirmProvider>` is mounted above it — one
 * at the root of the app, exactly like `<ToastProvider>`. Questions asked while
 * another is open queue up and are asked in turn; a dialog is modal, so there
 * is never more than one on screen.
 */

/** The confirming button: slate (`primary`), or `danger` when the answer destroys something. */
export type ConfirmIntent = "primary" | "danger"

export interface ConfirmOptions {
  /** The question. Also the dialog's accessible name. */
  title: React.ReactNode
  /** The consequence, in a line — what the user cannot undo afterwards. */
  description?: React.ReactNode
  /** Label on the confirming button. Default "Confirm". */
  confirmLabel?: string
  /** Label on the cancelling button. Default "Cancel". */
  cancelLabel?: string
  /** The confirming button's intent. Default "primary". */
  intent?: ConfirmIntent
}

export interface AlertDialogProps extends ConfirmOptions {
  /** Whether the dialog is on screen. Controlled — there is no uncontrolled shape. */
  isOpen: boolean
  /**
   * Asked to close: the confirming button, the cancelling button, or Escape.
   * Only ever called with `false`, because an alert dialog cannot open itself.
   */
  onOpenChange?: (isOpen: boolean) => void
  /** The user answered yes. Fired before `onOpenChange`. */
  onConfirm?: () => void
  /** The user answered no, or dismissed the dialog. Fired before `onOpenChange`. */
  onCancel?: () => void
  /** Panel width. Default "sm" — a question is not a form. */
  size?: ModalContentProps["size"]
  /** Extra detail between the description and the buttons. */
  children?: React.ReactNode
}

/**
 * A two-answer modal question: title, consequence, cancel, confirm.
 *
 * `role="alertdialog"` means it is not dismissable by clicking the scrim (the
 * Modal derives that from the role) and has no close icon, so the only ways out
 * are the two buttons and Escape — and Escape is a cancellation, because there
 * is no third answer to the question being asked.
 */
export function AlertDialog({
  isOpen,
  onOpenChange,
  onConfirm,
  onCancel,
  title,
  description,
  confirmLabel = "Confirm",
  cancelLabel = "Cancel",
  intent = "primary",
  size = "sm",
  children,
}: AlertDialogProps) {
  const answer = (confirmed: boolean) => {
    if (confirmed) onConfirm?.()
    else onCancel?.()
    onOpenChange?.(false)
  }

  return (
    <ModalContent
      role="alertdialog"
      size={size}
      closeButton={false}
      isOpen={isOpen}
      // The overlay only ever asks to *close* — Escape, or the focus scope
      // being dismissed — and the buttons below do not route through here (they
      // are not `slot="close"`), so this branch is the dismissal and nothing
      // else. Dismissing a question you cannot answer twice is answering "no".
      onOpenChange={(open) => {
        if (!open) answer(false)
      }}
    >
      <ModalHeader>
        <ModalTitle>{title}</ModalTitle>
        {description && <ModalDescription>{description}</ModalDescription>}
      </ModalHeader>
      {children && <ModalBody>{children}</ModalBody>}
      {/* Cancel first in the DOM: react-aria's Dialog moves focus to the first
          focusable element, and the safe answer is the one a stray Enter should
          land on. The footer reverses visually on mobile. */}
      <ModalFooter>
        <Button intent="outline" onPress={() => answer(false)}>
          {cancelLabel}
        </Button>
        <Button intent={intent} onPress={() => answer(true)}>
          {confirmLabel}
        </Button>
      </ModalFooter>
    </ModalContent>
  )
}

/** Ask a question; get the answer back. Rejects nothing — a dismissal is `false`. */
export type ConfirmFn = (question: string | ConfirmOptions) => Promise<boolean>

interface ConfirmRequest {
  id: string
  options: ConfirmOptions
  resolve: (answer: boolean) => void
}

const ConfirmContext = createContext<ConfirmFn | null>(null)

/**
 * The promise-based confirmation. Must be used under a `<ConfirmProvider>`.
 *
 * Takes the same options as `<AlertDialog>`, or a bare string when the question
 * is the whole of it, so a `confirm("Delete this project?")` becomes
 * `await confirm("Delete this project?")` and the branch below it survives.
 */
export function useConfirm(): ConfirmFn {
  const ctx = useContext(ConfirmContext)
  if (!ctx) throw new Error("useConfirm must be used within a <ConfirmProvider>")
  return ctx
}

export interface ConfirmProviderProps {
  children: React.ReactNode
  /** Options every question inherits unless it sets its own. */
  defaults?: Omit<ConfirmOptions, "title" | "description">
}

let counter = 0

/**
 * Mount once at the root of the app. Owns the queue and renders the dialog.
 */
export function ConfirmProvider({ children, defaults }: ConfirmProviderProps) {
  const [queue, setQueue] = useState<ConfirmRequest[]>([])
  const [shown, setShown] = useState<ConfirmRequest | null>(null)
  const [isOpen, setIsOpen] = useState(false)

  // Every question still awaiting an answer. Unmounting the provider settles
  // them as "no" rather than leaving each `await confirm(...)` pending forever,
  // which is a hang with no stack trace pointing anywhere near here.
  const outstanding = useRef(new Set<ConfirmRequest>())

  const confirm = useCallback<ConfirmFn>(
    (question) =>
      new Promise<boolean>((resolve) => {
        const request: ConfirmRequest = {
          id: `confirm-${++counter}`,
          options: typeof question === "string" ? { title: question } : question,
          resolve,
        }
        outstanding.current.add(request)
        setQueue((prev) => [...prev, request])
      }),
    [],
  )

  // One at a time: the head of the queue opens once the previous answer is in.
  // `shown` is deliberately not cleared on close — react-aria keeps the panel
  // mounted through its exit animation, and blanking the title mid-fade is the
  // flicker that would cost.
  useEffect(() => {
    if (isOpen || queue.length === 0) return
    setShown(queue[0])
    setQueue((prev) => prev.slice(1))
    setIsOpen(true)
  }, [isOpen, queue])

  useEffect(() => {
    const pending = outstanding.current
    return () => {
      for (const request of pending) request.resolve(false)
      pending.clear()
    }
  }, [])

  const settle = useCallback((request: ConfirmRequest | null, answer: boolean) => {
    // Escape fires `onCancel` and then `onOpenChange`; the membership check is
    // what keeps that from being two answers to one question.
    if (!request || !outstanding.current.has(request)) return
    outstanding.current.delete(request)
    request.resolve(answer)
    setIsOpen(false)
  }, [])

  return (
    <ConfirmContext.Provider value={confirm}>
      {children}
      {shown && (
        <AlertDialog
          {...defaults}
          {...shown.options}
          isOpen={isOpen}
          onConfirm={() => settle(shown, true)}
          onCancel={() => settle(shown, false)}
          onOpenChange={(open) => {
            if (!open) setIsOpen(false)
          }}
        />
      )}
    </ConfirmContext.Provider>
  )
}