ComponentsOverlay
Confirm popup
A small confirmation that opens beside the button that asked, with a message, Cancel and Confirm.
Import
import { ConfirmPopup, ConfirmPopupTrigger, ConfirmPopupContent } from "@booleanpress/ui/confirm-popup"Usage
Use it for a quick check on a single action in a row or a toolbar, where a full dialog would take the person away from what they were doing. The popup points at its trigger, and focus goes back there when it closes.
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"
export function DeleteEntry({ onDelete }: { onDelete: () => void }) {
return (
<ConfirmPopup tone="destructive" onConfirm={onDelete}>
<ConfirmPopupTrigger asChild>
<Button variant="outline">Delete entry</Button>
</ConfirmPopupTrigger>
<ConfirmPopupContent message="Delete this log entry?" />
</ConfirmPopup>
)
}ConfirmPopup holds the behaviour: onConfirm, onCancel (Cancel, Escape or a click outside), tone (default or destructive), defaultFocus, and open/onOpenChange to control it. When onConfirm returns a promise, the confirm button shows a spinner and the popup stays open until it settles; a rejection keeps it open with the error's message (an Error's or a string's, or the provider's actionFailed when it has none).
ConfirmPopupContent holds what it says: message and icon, or your own content as children; confirmLabel and cancelLabel (the provider's confirm, delete for a destructive one, and cancel); side and align (bottom and start by default; it flips when there is no room); and returnFocusTo for when the trigger is gone after the action.
For a decision that needs more words, or that must stop the page, use Confirm or Alert dialog.
Examples
Basic
An icon, the message, Cancel and Save, below the trigger and aligned to its start; focus starts on Save.
import { TriangleAlertIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"
export default function ConfirmPopupBasic() {
const [saved, setSaved] = useState(false)
return (
<div className="flex flex-col items-center gap-3">
<ConfirmPopup onConfirm={() => setSaved(true)}>
<ConfirmPopupTrigger asChild>
<Button variant="outline">Save</Button>
</ConfirmPopupTrigger>
<ConfirmPopupContent
icon={<TriangleAlertIcon />}
message="Save the changes to the Primary mailer?"
confirmLabel="Save"
/>
</ConfirmPopup>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{saved ? "Changes saved." : ""}
</p>
</div>
)
}Destructive
tone="destructive": a red Delete button, and focus starts on Cancel.
import { InfoIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"
export default function ConfirmPopupDestructive() {
const [deleted, setDeleted] = useState(false)
return (
<div className="flex flex-col items-center gap-3">
<ConfirmPopup tone="destructive" onConfirm={() => setDeleted(true)}>
<ConfirmPopupTrigger asChild>
<Button variant="outline" severity="danger" disabled={deleted}>
Delete entry
</Button>
</ConfirmPopupTrigger>
<ConfirmPopupContent icon={<InfoIcon />} message="Delete this log entry?" />
</ConfirmPopup>
<p className="min-h-5 text-sm text-muted-foreground" aria-live="polite">
{deleted ? "The entry was deleted." : ""}
</p>
</div>
)
}Custom content
Your own content in place of the message, and an onConfirm that waits for a request.
import { KeyRoundIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"
// Stands in for the request that issues a new key.
const regenerate = () => new Promise<void>((resolve) => setTimeout(resolve, 1200))
export default function ConfirmPopupCustomContent() {
return (
<ConfirmPopup onConfirm={regenerate}>
<ConfirmPopupTrigger asChild>
<Button variant="outline">
<KeyRoundIcon />
Regenerate key
</Button>
</ConfirmPopupTrigger>
<ConfirmPopupContent confirmLabel="Regenerate">
<div className="flex flex-col gap-1.5">
<p className="font-medium text-foreground">Regenerate the Staging key?</p>
<p className="text-muted-foreground">
The key ending in <code className="rounded-sm bg-muted px-1 font-mono text-xs">7f3a</code> stops working.
</p>
</div>
</ConfirmPopupContent>
</ConfirmPopup>
)
}Placement
side on each of the four sides, with align="center".
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"
const SIDES = ["top", "right", "bottom", "left"] as const
export default function ConfirmPopupPlacement() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
{SIDES.map((side) => (
<ConfirmPopup key={side}>
<ConfirmPopupTrigger asChild>
<Button variant="outline" className="capitalize">
{side}
</Button>
</ConfirmPopupTrigger>
<ConfirmPopupContent side={side} align="center" message="Resend the welcome email?" confirmLabel="Resend" />
</ConfirmPopup>
))}
</div>
)
}Async error
onConfirm returns a promise that rejects: the popup stays open and shows the error's message.
import { TriangleAlertIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { ConfirmPopup, ConfirmPopupContent, ConfirmPopupTrigger } from "@booleanpress/ui/confirm-popup"
// A pretend request that fails after 800 ms: the popup keeps its spinner until then, then stays open with the message.
const publish = () =>
new Promise<void>((_, reject) => {
setTimeout(() => reject(new Error("Acme Mail could not be reached. Try again in a minute.")), 800)
})
export default function ConfirmPopupAsyncError() {
return (
<ConfirmPopup onConfirm={publish}>
<ConfirmPopupTrigger asChild>
<Button variant="outline">Publish</Button>
</ConfirmPopupTrigger>
<ConfirmPopupContent
icon={<TriangleAlertIcon />}
message="Publish the Welcome template to all subscribers?"
confirmLabel="Publish"
/>
</ConfirmPopup>
)
}Accessibility
- Semantics
- A
divwithrole="alertdialog"andaria-modal="true", named by its message (or your content). The rest of the page is hidden from assistive technology while it is open. The trigger hasaria-haspopup="dialog"andaria-expanded. WhileonConfirmruns, the popup isaria-busyand the confirm buttonaria-busyandaria-disabled. - Labels
- Write the message as the question. The buttons' default labels are the provider's
confirm,deleteandcancel. An error fromonConfirmis shown in arole="alert"paragraph. - Focus
- Focus moves to Confirm when it opens, or to Cancel when
toneisdestructive(defaultFocuschooses), and stays inside. It returns to the trigger when the popup closes, or toreturnFocusTowhen the trigger is gone. - Known limits
- It is modal: while it is open, a click outside closes it (as Cancel) and does not reach what it lands on.
- Keep the message to a line or two; a longer explanation belongs in a dialog.
Keyboard
| Key | Behaviour |
|---|---|
| EnterorSpace | On the trigger, opens the popup; on a button, activates it. |
| Escape | Cancels and closes the popup; focus returns to the trigger. Ignored while onConfirm runs. |
| Tab | Moves between Cancel and Confirm, wrapping. |
| ShiftTab | Moves between Confirm and Cancel, wrapping. |
API
ConfirmPopup
| Prop | Type | Default | Description |
|---|---|---|---|
defaultFocus | "confirm" | "cancel" | The button focused when the popup opens: Confirm, or Cancel when tone is destructive. | |
defaultOpen | boolean | false | Whether it starts open, when it controls itself. |
onCancel | (() => void) | Called when the popup closes without confirming: Cancel, Escape, or a click outside. | |
onConfirm | (() => unknown) | Runs when the person confirms. When it returns a promise, the confirm button shows a spinner and the popup stays
open until it settles: resolved, it closes; rejected, it stays open with the error's message (the provider's
actionFailed when it has none). | |
onOpenChange | ((open: boolean) => void) | Called with true or false when it opens or closes. | |
open | boolean | Whether it is open, when you control it. Pair it with onOpenChange. | |
tone | "default" | "destructive" | default | destructive paints the confirm button red and starts focus on Cancel. |
ConfirmPopupTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | Render the child element instead, with this part's behaviour merged onto it. |
ConfirmPopupContent
| Prop | Type | Default | Description |
|---|---|---|---|
align | "center" | "start" | "end" | start | Its alignment along that side: start (default), center or end. |
alignOffset | number | Pixels to shift it along that side. | |
arrowPadding | number | ||
asChild | boolean | ||
avoidCollisions | boolean | ||
cancelLabel | ReactNode | The cancel button's text. Defaults to the provider's cancel. | |
children | ReactNode | Your own content in place of the icon and message; it names the popup. | |
collisionBoundary | Boundary | Boundary[] | ||
collisionPadding | number | Partial<Record<"top" | "bottom" | "left" | "right", number>> | Pixels it keeps from the window's edges. 8 by default. | |
confirmLabel | ReactNode | The confirm button's text. Defaults to the provider's confirm, or delete when tone is destructive. | |
deferPointerDownOutside | boolean | When true, a 'pointerdown' event outside of the layered element will
wait for the interaction's click event before dispatching, allowing
third-party code to stop propagation of later events and cancel dismissal. | |
forceMount | true | Used to force mounting when more control is needed. Useful when controlling animation with React animation libraries. | |
hideWhenDetached | boolean | ||
icon | ReactNode | An icon before the message, 20 px. | |
message | ReactNode | The question. | |
onCloseAutoFocus | ((event: Event) => void) | Event handler called when auto-focusing on close. Can be prevented. | |
onEscapeKeyDown | ((event: KeyboardEvent) => void) | Event handler called when the escape key is down. Can be prevented. | |
onFocusOutside | ((event: FocusOutsideEvent) => void) | Event handler called when the focus moves outside of the DismissableLayer.
Can be prevented. | |
onInteractOutside | ((event: FocusOutsideEvent | PointerDownOutsideEvent) => void) | Event handler called when an interaction happens outside the DismissableLayer.
Specifically, when a pointerdown event happens outside or focus moves outside of it.
Can be prevented. | |
onOpenAutoFocus | ((event: Event) => void) | Event handler called when auto-focusing on open. Can be prevented. | |
onPointerDownOutside | ((event: PointerDownOutsideEvent) => void) | Event handler called when the a pointerdown event happens outside of the DismissableLayer.
Can be prevented. | |
returnFocusTo | ReturnFocusTarget | Where focus goes when the popup closes and its trigger is gone, for example after the confirmed delete. | |
side | "top" | "bottom" | "left" | "right" | bottom | The side of the trigger it opens on: top, right, bottom (default) or left. It flips when there is no room. |
sideOffset | number | ||
sticky | "always" | "partial" | ||
updatePositionStrategy | "always" | "optimized" |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="confirm-popup-trigger" (ConfirmPopupTrigger), data-slot="confirm-popup-content" (ConfirmPopupContent), and data-tone, data-loading.
Provider strings: actionFailed, cancel, delete, confirm (BooleanUIProvider's strings).
Theming
A floating surface: --popover, --popover-foreground and --border, 6px radius, the overlay shadow; its arrow takes the same fill and edge. The buttons are the small Button: Cancel outline in the muted text colour, Confirm default or destructive.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--border | stroke |
--destructive-strong | text |
--foreground | text |
--muted-foreground | text |
--popover | fill |