Skip to the content

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 div with role="alertdialog" and aria-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 has aria-haspopup="dialog" and aria-expanded. While onConfirm runs, the popup is aria-busy and the confirm button aria-busy and aria-disabled.
Labels
Write the message as the question. The buttons' default labels are the provider's confirm, delete and cancel. An error from onConfirm is shown in a role="alert" paragraph.
Focus
Focus moves to Confirm when it opens, or to Cancel when tone is destructive (defaultFocus chooses), and stays inside. It returns to the trigger when the popup closes, or to returnFocusTo when 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

Keyboard
KeyBehaviour
EnterorSpaceOn the trigger, opens the popup; on a button, activates it.
EscapeCancels and closes the popup; focus returns to the trigger. Ignored while onConfirm runs.
TabMoves between Cancel and Confirm, wrapping.
ShiftTabMoves between Confirm and Cancel, wrapping.

API

ConfirmPopup

ConfirmPopup props
PropTypeDefaultDescription
defaultFocus"confirm" | "cancel"The button focused when the popup opens: Confirm, or Cancel when tone is destructive.
defaultOpenbooleanfalseWhether 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.
openbooleanWhether it is open, when you control it. Pair it with onOpenChange.
tone"default" | "destructive"defaultdestructive paints the confirm button red and starts focus on Cancel.

ConfirmPopupTrigger

ConfirmPopupTrigger props
PropTypeDefaultDescription
asChildbooleanRender the child element instead, with this part's behaviour merged onto it.

ConfirmPopupContent

ConfirmPopupContent props
PropTypeDefaultDescription
align"center" | "start" | "end"startIts alignment along that side: start (default), center or end.
alignOffsetnumberPixels to shift it along that side.
arrowPaddingnumber
asChildboolean
avoidCollisionsboolean
cancelLabelReactNodeThe cancel button's text. Defaults to the provider's cancel.
childrenReactNodeYour own content in place of the icon and message; it names the popup.
collisionBoundaryBoundary | Boundary[]
collisionPaddingnumber | Partial<Record<"top" | "bottom" | "left" | "right", number>>Pixels it keeps from the window's edges. 8 by default.
confirmLabelReactNodeThe confirm button's text. Defaults to the provider's confirm, or delete when tone is destructive.
deferPointerDownOutsidebooleanWhen 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.
forceMounttrueUsed to force mounting when more control is needed. Useful when controlling animation with React animation libraries.
hideWhenDetachedboolean
iconReactNodeAn icon before the message, 20 px.
messageReactNodeThe 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.
returnFocusToReturnFocusTargetWhere focus goes when the popup closes and its trigger is gone, for example after the confirmed delete.
side"top" | "bottom" | "left" | "right"bottomThe side of the trigger it opens on: top, right, bottom (default) or left. It flips when there is no room.
sideOffsetnumber
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.

Theme tokens
TokenUsed for
--borderstroke
--destructive-strongtext
--foregroundtext
--muted-foregroundtext
--popoverfill