Skip to content

Confirmation

A destructive-action guard with a static backdrop, a pending state and a confirm button that is never focused first.

Overlaystarterconfirmdestructivedeleteguard

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

'use client'

import { useState } from 'react'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/dialog'
import { DemoRow, DemoStage } from './_kit'

export default function ConfirmDialogDemo() {
  const [open, setOpen] = useState(false)
  const [pending, setPending] = useState(false)
  const [result, setResult] = useState<string | null>(null)

  const confirm = () => {
    setPending(true)
    // Simulated latency so the pending state is visible. No request is made.
    setTimeout(() => {
      setPending(false)
      setOpen(false)
      setResult('Project deleted — in this demo, nothing was actually removed.')
    }, 900)
  }

  return (
    <DemoStage>
      <DemoRow
        label="Destructive confirmation"
        description="Backdrop press is disabled and the confirm button is never focused first."
      >
        <Button variant="destructive" onClick={() => setOpen(true)}>
          Delete project
        </Button>
      </DemoRow>

      {result ? (
        <Alert tone="success" title="Confirmed">
          {result}
        </Alert>
      ) : null}

      <ConfirmDialog
        open={open}
        onClose={() => setOpen(false)}
        onConfirm={confirm}
        loading={pending}
        title="Delete “Acme Platform”?"
        description="This removes every deployment, log and API key in the project. It cannot be undone."
        confirmLabel="Delete project"
      />
    </DemoStage>
  )
}

components/demos/confirm-dialog.tsx

'use client'

import { useState } from 'react'
import { Alert } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/dialog'
import { DemoRow, DemoStage } from './_kit'

export default function ConfirmDialogDemo() {
  const [open, setOpen] = useState(false)
  const [pending, setPending] = useState(false)
  const [result, setResult] = useState<string | null>(null)

  const confirm = () => {
    setPending(true)
    // Simulated latency so the pending state is visible. No request is made.
    setTimeout(() => {
      setPending(false)
      setOpen(false)
      setResult('Project deleted — in this demo, nothing was actually removed.')
    }, 900)
  }

  return (
    <DemoStage>
      <DemoRow
        label="Destructive confirmation"
        description="Backdrop press is disabled and the confirm button is never focused first."
      >
        <Button variant="destructive" onClick={() => setOpen(true)}>
          Delete project
        </Button>
      </DemoRow>

      {result ? (
        <Alert tone="success" title="Confirmed">
          {result}
        </Alert>
      ) : null}

      <ConfirmDialog
        open={open}
        onClose={() => setOpen(false)}
        onConfirm={confirm}
        loading={pending}
        title="Delete “Acme Platform”?"
        description="This removes every deployment, log and API key in the project. It cannot be undone."
        confirmLabel="Delete project"
      />
    </DemoStage>
  )
}

components/ui/dialog.tsx

'use client'

import { useId, useRef, type ReactNode } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { useFocusTrap } from '@/hooks/use-focus-trap'
import { useDismiss, useScrollLock } from '@/hooks/use-dismiss'
import { Portal } from './portal'
import { Button } from './button'

/**
 * Dialog
 *
 * A modal built on the ARIA dialog pattern rather than `<dialog>`, because the
 * native element still varies too much across engines in how it handles
 * scroll locking, backdrop styling and Escape inside nested layers.
 *
 * The contract implemented here: focus moves in on open, is trapped while
 * open, Escape and backdrop press close, body scroll is locked, and focus
 * returns to the trigger on close.
 */
export interface DialogProps {
  open: boolean
  onClose: () => void
  title: string
  description?: string
  children?: ReactNode
  footer?: ReactNode
  size?: 'sm' | 'md' | 'lg' | 'xl'
  /** Hides the corner close button when the dialog demands an explicit choice. */
  hideClose?: boolean
  /** Blocks backdrop dismissal for destructive confirmations. */
  staticBackdrop?: boolean
}

const sizes = {
  sm: 'max-w-sm',
  md: 'max-w-md',
  lg: 'max-w-lg',
  xl: 'max-w-2xl',
} as const

export function Dialog({
  open,
  onClose,
  title,
  description,
  children,
  footer,
  size = 'md',
  hideClose = false,
  staticBackdrop = false,
}: DialogProps) {
  const panelRef = useRef<HTMLDivElement>(null)
  const uid = useId()
  const titleId = `${uid}-title`
  const descriptionId = description ? `${uid}-description` : undefined

  useFocusTrap(panelRef, open)
  useScrollLock(open)
  useDismiss([panelRef], open, onClose, { outside: !staticBackdrop })

  if (!open) return null

  return (
    <Portal>
      <div className="fixed inset-0 z-[100] flex items-end justify-center p-4 sm:items-center">
        <div className="animate-fade-in absolute inset-0 bg-scrim" aria-hidden="true" />
        <div
          ref={panelRef}
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          aria-describedby={descriptionId}
          tabIndex={-1}
          className={cn(
            'animate-scale-in relative flex max-h-[85vh] w-full flex-col overflow-hidden rounded-xl border border-line bg-surface-raised shadow-overlay',
            sizes[size],
          )}
        >
          <div className="flex items-start justify-between gap-4 border-b border-line-subtle px-5 py-4">
            <div className="min-w-0">
              <h2 id={titleId} className="text-md font-semibold text-ink-strong">
                {title}
              </h2>
              {description ? (
                <p id={descriptionId} className="mt-1 text-sm text-ink-muted">
                  {description}
                </p>
              ) : null}
            </div>
            {!hideClose ? (
              <Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close dialog">
                <X className="size-4" aria-hidden="true" />
              </Button>
            ) : null}
          </div>

          {children ? (
            <div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4 text-sm">
              {children}
            </div>
          ) : null}

          {footer ? (
            <div className="flex flex-wrap items-center justify-end gap-2 border-t border-line-subtle bg-surface-sunken px-5 py-3">
              {footer}
            </div>
          ) : null}
        </div>
      </div>
    </Portal>
  )
}

export interface ConfirmDialogProps {
  open: boolean
  onClose: () => void
  onConfirm: () => void
  title: string
  description: string
  confirmLabel?: string
  cancelLabel?: string
  tone?: 'danger' | 'accent'
  loading?: boolean
  /** Requires typing this exact string before confirming — for irreversible acts. */
  confirmPhrase?: string
}

/**
 * ConfirmDialog
 *
 * A destructive-action guard. The backdrop is static and the confirm button is
 * never the initially focused element, so a stray Enter cannot delete anything.
 */
export function ConfirmDialog({
  open,
  onClose,
  onConfirm,
  title,
  description,
  confirmLabel = 'Confirm',
  cancelLabel = 'Cancel',
  tone = 'danger',
  loading = false,
}: ConfirmDialogProps) {
  return (
    <Dialog
      open={open}
      onClose={onClose}
      title={title}
      description={description}
      size="sm"
      staticBackdrop
      footer={
        <>
          <Button variant="outline" onClick={onClose} disabled={loading}>
            {cancelLabel}
          </Button>
          <Button
            variant={tone === 'danger' ? 'destructive' : 'primary'}
            onClick={onConfirm}
            loading={loading}
          >
            {confirmLabel}
          </Button>
        </>
      }
    />
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For irreversible actions. Two details do the real work: the backdrop cannot dismiss it, and the confirm button is not the initially focused element — so a stray Enter cannot delete anything.

  • Name the object in the title: "Delete Acme Platform?" beats "Are you sure?".
  • Say what will be lost, in the description, before asking for confirmation.
  • Prefer an undo Toast for anything genuinely reversible; a confirmation costs every user time to protect a few.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Danger
  • Accent
  • Loading
  • Custom labels

Accessibility

Initial focus
Focus lands on the first focusable element, which is Cancel.
Busy state
The confirm button reports `aria-busy` while pending, and both buttons are disabled.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.