ComponentsOverlay
Confirm
Asks a yes-or-no question from code: confirm() opens a confirmation dialog and resolves to true or false.
Import
import { ConfirmProvider } from "@booleanpress/ui/confirm"Usage
Render ConfirmProvider once, inside BooleanUIProvider, around the part of the app that asks. Below it, useConfirm() returns confirm: call it from an event handler and await the answer. There is no dialog markup to write and no open state to keep.
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"
function DeleteMailer({ onDelete }: { onDelete: () => void }) {
const confirm = useConfirm()
const remove = async () => {
if (await confirm({ title: "Delete the Staging mailer?", description: "This cannot be undone.", tone: "destructive" })) {
onDelete()
}
}
return <Button variant="destructive" onClick={remove}>Delete mailer</Button>
}
export function App() {
return (
<ConfirmProvider>
<DeleteMailer onDelete={() => {}} />
</ConfirmProvider>
)
}confirm(options) takes title (the provider's confirmTitle, "Are you sure?", by default), description, confirmLabel and cancelLabel (the provider's confirm and cancel; delete for a destructive one), tone (default or destructive), icon, defaultFocus, showCloseButton and returnFocusTo. It resolves true when the person confirms and false for Cancel, the ร or Escape.
Pass onConfirm to run the work inside the dialog: while its promise is pending the confirm button shows a spinner, Cancel and Escape wait, and the dialog stays open. Resolved, the dialog closes and confirm() resolves true; rejected, the dialog stays open with the error's message (an Error's or a string's, or the provider's actionFailed when it has none), for another try or Cancel.
Confirmations asked while one is open wait their turn and open one after another. Each provider has its own queue, so a page may have several, and nothing is kept in module scope: it renders on the server. useConfirm() outside a provider throws. For a confirmation you write as markup, with your own content, use Alert dialog; for a small one beside the button, Confirm popup.
Examples
Basic
await confirm() with a title, a description and a confirm label; focus starts on Confirm.
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"
function SendTest() {
const confirm = useConfirm()
const [result, setResult] = useState("")
const send = async () => {
const confirmed = await confirm({
title: "Send a test email?",
description: "A test message goes to admin@example.com through the Primary mailer.",
confirmLabel: "Send",
})
setResult(confirmed ? "Test email sent." : "Nothing was sent.")
}
return (
<div className="flex flex-col items-center gap-3">
<Button onClick={send}>Send test email</Button>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{result}
</p>
</div>
)
}
export default function ConfirmBasic() {
return (
<ConfirmProvider>
<SendTest />
</ConfirmProvider>
)
}Destructive
tone="destructive": a red Delete button, and focus starts on Cancel.
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"
function DeleteMailer() {
const confirm = useConfirm()
const [deleted, setDeleted] = useState(false)
const remove = async () => {
const confirmed = await confirm({
title: "Delete the Staging mailer?",
description: "Emails queued for it stay in the log, but they are not sent. This cannot be undone.",
tone: "destructive",
})
if (confirmed) setDeleted(true)
}
return (
<div className="flex flex-col items-center gap-3">
<Button variant="destructive" onClick={remove} disabled={deleted}>
Delete mailer
</Button>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{deleted ? "The Staging mailer was deleted." : ""}
</p>
</div>
)
}
export default function ConfirmDestructive() {
return (
<ConfirmProvider>
<DeleteMailer />
</ConfirmProvider>
)
}Custom labels and icon
Your own button labels, an icon before the description, and defaultFocus="cancel".
import { KeyRoundIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"
function RotateKey() {
const confirm = useConfirm()
const [rotated, setRotated] = useState(false)
const rotate = async () => {
const confirmed = await confirm({
title: "Rotate the API key?",
description: "Sites that use the current key stop sending until you paste the new one.",
icon: <KeyRoundIcon />,
confirmLabel: "Rotate key",
cancelLabel: "Keep current key",
defaultFocus: "cancel",
})
if (confirmed) setRotated(true)
}
return (
<div className="flex flex-col items-center gap-3">
<Button variant="outline" onClick={rotate}>
Rotate API key
</Button>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{rotated ? "A new key was created." : ""}
</p>
</div>
)
}
export default function ConfirmCustom() {
return (
<ConfirmProvider>
<RotateKey />
</ConfirmProvider>
)
}Async confirm
onConfirm returns a promise: the button shows a spinner and the dialog stays open until it resolves.
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"
// Stands in for the request that revokes the key.
const revokeKey = () => new Promise<void>((resolve) => setTimeout(resolve, 1500))
function RevokeKey() {
const confirm = useConfirm()
const [revoked, setRevoked] = useState(false)
const revoke = async () => {
const confirmed = await confirm({
title: "Revoke the Production key?",
description: "Requests signed with it are refused from now on.",
tone: "destructive",
confirmLabel: "Revoke",
onConfirm: revokeKey,
})
if (confirmed) setRevoked(true)
}
return (
<div className="flex flex-col items-center gap-3">
<Button variant="outline" onClick={revoke} disabled={revoked}>
Revoke key
</Button>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{revoked ? "The Production key was revoked." : ""}
</p>
</div>
)
}
export default function ConfirmAsync() {
return (
<ConfirmProvider>
<RevokeKey />
</ConfirmProvider>
)
}Async error
The first attempt rejects: the dialog stays open with the error's message, and Revoke can be tried again.
import { useRef, useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"
function PurgeQueue() {
const confirm = useConfirm()
const attempts = useRef(0)
const [purged, setPurged] = useState(false)
// The first attempt fails, as a timed-out request would; the second succeeds.
const purgeQueue = () =>
new Promise<void>((resolve, reject) => {
attempts.current += 1
const fails = attempts.current === 1
setTimeout(() => (fails ? reject(new Error("The queue did not answer in time. Try again.")) : resolve()), 1200)
})
const purge = async () => {
const confirmed = await confirm({
title: "Purge the email queue?",
description: "The 38 emails waiting to be sent are removed.",
tone: "destructive",
confirmLabel: "Purge",
onConfirm: purgeQueue,
})
if (confirmed) setPurged(true)
}
return (
<div className="flex flex-col items-center gap-3">
<Button variant="outline" onClick={purge} disabled={purged}>
Purge queue
</Button>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{purged ? "The queue is empty." : ""}
</p>
</div>
)
}
export default function ConfirmAsyncError() {
return (
<ConfirmProvider>
<PurgeQueue />
</ConfirmProvider>
)
}Queued
Two confirmations asked at once open one after the other; focus returns to the button at the end.
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmProvider, useConfirm } from "@booleanpress/ui/confirm"
const MAILERS = ["Primary SMTP", "Transactional SES"]
function ImportMailers() {
const confirm = useConfirm()
const [result, setResult] = useState("")
// Both questions are asked at once; the second dialog opens when the first is answered.
const importMailers = async () => {
const answers = await Promise.all(
MAILERS.map((name) =>
confirm({
title: `Replace ${name}?`,
description: `The imported file has settings for ${name}. Replacing them changes how its emails are sent.`,
confirmLabel: "Replace",
cancelLabel: "Skip",
})
)
)
const replaced = MAILERS.filter((_, index) => answers[index])
setResult(replaced.length ? `Replaced: ${replaced.join(", ")}.` : "Nothing was replaced.")
}
return (
<div className="flex flex-col items-center gap-3">
<Button variant="outline" onClick={importMailers}>
Import 2 mailers
</Button>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{result}
</p>
</div>
)
}
export default function ConfirmQueued() {
return (
<ConfirmProvider>
<ImportMailers />
</ConfirmProvider>
)
}Accessibility
- Semantics
- The library's Alert dialog: a
divwithrole="alertdialog"andaria-modal="true", named by the title and described by the description. The rest of the page is hidden from assistive technology while it is open. WhileonConfirmruns, the dialog isaria-busyand the confirm buttonaria-busyandaria-disabled. - Labels
- The title names the dialog; give every confirmation a question as its title. The ร is named by the provider's
closestring; the buttons' default labels areconfirm,deleteandcancel. An error fromonConfirmis shown in arole="alert"paragraph, so it is read at once; without a message of its own it reads the provider'sactionFailed. - Focus
- Focus starts on Confirm, or on Cancel when
toneisdestructive(defaultFocuschooses), and stays inside. When the last waiting confirmation closes, focus returns to the element that had it when the first was asked (the menu's button when a menu item asked, since the item closes with its menu), or toreturnFocusTowhen that is gone too. Between queued confirmations it moves straight to the next dialog. The buttons keep focus whileonConfirmruns. - Known limits
- One confirmation at a time: the others wait, in the order they were asked.
- There is no text-input (prompt) variant. Ask for text in a Dialog with a form.
- A click on the backdrop does nothing, as in every alert dialog: the person answers with a button or Escape.
Keyboard
| Key | Behaviour |
|---|---|
| EnterorSpace | Activates the focused button: Confirm resolves true, Cancel and the ร resolve false. |
| Escape | Cancels: the dialog closes and confirm() resolves false. Ignored while onConfirm runs. |
| Tab | Moves to the next button inside the dialog, wrapping from the last to the first. |
| ShiftTab | Moves to the previous button inside the dialog, wrapping from the first to the last. |
API
ConfirmProvider
Also exported: useConfirm, a hook for the parts' shared state; call it inside the component's provider.
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="confirm-content" (ConfirmProvider), and data-tone, data-loading.
Provider strings: actionFailed, confirmTitle, cancel, delete, confirm, close (BooleanUIProvider's strings).
Theming
The dialog is the library's Alert dialog at 352 px: --popover and --popover-foreground, the --mask backdrop, the Button colours for its buttons (Cancel is secondary; Confirm default, or destructive with tone="destructive").
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--accent | background |
--destructive-strong | text |
--foreground | text |
--muted-foreground | text |
--subtle | background |