# Confirm Popover

> A small anchored confirmation popover with destructive tone, safe default focus and async confirm.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/confirm-popover
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/confirm-popover.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/confirm-popover-demo.tsx"
"use client"

import * as React from "react"
import { Archive, FolderKanban, RotateCcw, Trash2 } from "lucide-react"

import { ConfirmPopover } from "@/components/confirm-popover"

const initial = [
  { id: "atlas", name: "Atlas redesign", meta: "12 files · updated 2h ago" },
  { id: "pulse", name: "Pulse analytics", meta: "48 files · updated yesterday" },
  { id: "orbit", name: "Orbit mobile app", meta: "31 files · updated 3 days ago" },
]

const iconButton =
  "flex size-9 items-center justify-center rounded-lg border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"

export default function ConfirmPopoverDemo() {
  const [projects, setProjects] = React.useState(initial)
  const [archived, setArchived] = React.useState<string[]>([])

  const remove = (id: string) => async () => {
    await new Promise((resolve) => setTimeout(resolve, 1200))
    setProjects((list) => list.filter((p) => p.id !== id))
  }

  return (
    <div className="mx-auto flex min-h-[26rem] w-full max-w-md flex-col gap-4">
      <div className="flex items-center justify-between gap-3">
        <h3 className="text-sm font-semibold">Projects</h3>
        <button
          type="button"
          onClick={() => {
            setProjects(initial)
            setArchived([])
          }}
          className="inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"
        >
          <RotateCcw className="size-3.5" aria-hidden /> Reset
        </button>
      </div>

      <ul className="grid gap-2">
        {projects.map((project) => (
          <li key={project.id} className="flex items-center gap-3 rounded-xl border bg-card p-3 shadow-xs">
            <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
              <FolderKanban className="size-5" aria-hidden />
            </span>
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm font-medium">{project.name}</p>
              <p className="truncate text-xs text-muted-foreground">
                {archived.includes(project.id) ? "Archived" : project.meta}
              </p>
            </div>
            <ConfirmPopover
              tone="default"
              title="Archive this project?"
              description="It will be hidden from your dashboard. You can restore it later."
              confirmLabel="Archive"
              align="end"
              onConfirm={() => setArchived((list) => [...list, project.id])}
            >
              <button type="button" aria-label={`Archive ${project.name}`} className={iconButton}>
                <Archive className="size-4" aria-hidden />
              </button>
            </ConfirmPopover>
            <ConfirmPopover
              title="Delete this project?"
              description={`“${project.name}” and all of its files will be permanently removed.`}
              confirmLabel="Delete"
              align="end"
              onConfirm={remove(project.id)}
            >
              <button type="button" aria-label={`Delete ${project.name}`} className={iconButton}>
                <Trash2 className="size-4" aria-hidden />
              </button>
            </ConfirmPopover>
          </li>
        ))}
      </ul>

      {projects.length === 0 && (
        <p className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
          All projects deleted. Use Reset to start over.
        </p>
      )}
    </div>
  )
}
```

## Source

```tsx title="components/confirm-popover.tsx"
"use client"

import * as React from "react"
import { Loader2, TriangleAlert } from "lucide-react"

import { cn } from "@/lib/utils"

export interface ConfirmPopoverProps extends Omit<React.ComponentProps<"div">, "children" | "title"> {
  /** The element that opens the popover. Any single element, usually a button. */
  children: React.ReactElement<React.HTMLAttributes<HTMLElement>>
  title: React.ReactNode
  description?: React.ReactNode
  confirmLabel?: string
  cancelLabel?: string
  tone?: "destructive" | "default"
  /** May return a promise; the confirm button shows a spinner until it settles. */
  onConfirm: () => void | Promise<void>
  side?: "top" | "bottom"
  align?: "start" | "center" | "end"
  /** Panel width in px (never wider than the viewport) */
  width?: number
}

const sides = { top: "bottom-full mb-2 origin-bottom", bottom: "top-full mt-2 origin-top" } as const
const aligns = { start: "left-0", center: "left-1/2 -translate-x-1/2", end: "right-0" } as const
const focusable = "button, a[href], [role='button'], [tabindex]"

export function ConfirmPopover({
  children,
  title,
  description,
  confirmLabel = "Confirm",
  cancelLabel = "Cancel",
  tone = "destructive",
  onConfirm,
  side = "bottom",
  align = "center",
  width = 288,
  className,
  ...props
}: ConfirmPopoverProps) {
  const [open, setOpen] = React.useState(false)
  const [pending, setPending] = React.useState(false)
  const root = React.useRef<HTMLDivElement>(null)
  const cancel = React.useRef<HTMLButtonElement>(null)
  const confirm = React.useRef<HTMLButtonElement>(null)
  const returnTo = React.useRef<HTMLElement | null>(null)
  const id = React.useId()

  const close = React.useCallback(() => {
    setOpen(false)
    returnTo.current?.focus()
  }, [])

  React.useEffect(() => {
    if (!open) return
    cancel.current?.focus()
  }, [open])

  React.useEffect(() => {
    if (!open || pending) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") close()
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onKeyDown)
    }
  }, [open, pending, close])

  async function handleConfirm() {
    setPending(true)
    try {
      await onConfirm()
    } finally {
      setPending(false)
      close()
    }
  }

  // Keep Tab inside the two buttons while the panel is open
  function onPanelKeyDown(event: React.KeyboardEvent) {
    if (event.key !== "Tab") return
    const first = cancel.current
    const last = confirm.current
    if (!first || !last) return
    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault()
      last.focus()
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault()
      first.focus()
    }
  }

  const trigger = React.isValidElement(children)
    ? React.cloneElement(children, {
        "aria-haspopup": "dialog",
        "aria-expanded": open,
        "aria-controls": open ? id : undefined,
      })
    : children

  const destructive = tone === "destructive"

  return (
    <div ref={root} className={cn("relative inline-flex", className)} {...props}>
      <span
        className="inline-flex"
        onClick={(event) => {
          if (pending) return
          if (open) {
            close()
            return
          }
          returnTo.current = (event.target as HTMLElement).closest<HTMLElement>(focusable)
          setOpen(true)
        }}
      >
        {trigger}
      </span>
      {open && (
        <div
          id={id}
          role="alertdialog"
          aria-labelledby={`${id}-title`}
          aria-describedby={description ? `${id}-desc` : undefined}
          aria-busy={pending}
          onKeyDown={onPanelKeyDown}
          style={{ width }}
          className={cn(
            "absolute z-30 max-w-[calc(100vw-2rem)] animate-in rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg duration-150 fade-in zoom-in-95",
            sides[side],
            aligns[align]
          )}
        >
          <div className="flex items-start gap-3">
            {destructive && (
              <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
                <TriangleAlert className="size-4" aria-hidden />
              </span>
            )}
            <div className="min-w-0 flex-1">
              <p id={`${id}-title`} className="text-sm font-semibold">
                {title}
              </p>
              {description && (
                <p id={`${id}-desc`} className="mt-1 text-sm text-muted-foreground">
                  {description}
                </p>
              )}
            </div>
          </div>
          <div className="mt-4 flex justify-end gap-2">
            <button
              ref={cancel}
              type="button"
              disabled={pending}
              onClick={close}
              className="inline-flex h-8 items-center justify-center rounded-lg border bg-background px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
            >
              {cancelLabel}
            </button>
            <button
              ref={confirm}
              type="button"
              disabled={pending}
              onClick={handleConfirm}
              className={cn(
                "inline-flex h-8 min-w-20 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors focus-visible:ring-4 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-70",
                destructive
                  ? "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/30"
                  : "bg-primary text-primary-foreground hover:opacity-90 focus-visible:ring-ring/40"
              )}
            >
              {pending && <Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden />}
              {pending ? "Working…" : confirmLabel}
            </button>
          </div>
        </div>
      )}
    </div>
  )
}
```
