UI Library

Confirm dialog

A destructive-action modal with the accessibility work done: focus moves in on open and returns to the trigger on close, Escape and backdrop-click dismiss, background scroll is locked, and Tab is trapped inside.

Overlaymodaldialogdestructiveaccessible
Preview
apps/site/blocks/components/ConfirmDialog.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { AlertTriangle } from 'lucide-react';

export function ConfirmDialog({
  triggerLabel = 'Delete workspace',
  title = 'Delete this workspace?',
  body = 'Every project, task and note inside it is removed. This cannot be undone.',
  confirmLabel = 'Delete',
  cancelLabel = 'Cancel',
  onConfirm,
}: {
  triggerLabel?: string;
  title?: string;
  body?: string;
  confirmLabel?: string;
  cancelLabel?: string;
  onConfirm?: () => void;
}) {
  const [open, setOpen] = useState(false);
  const panelRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (!open) return;

    // Focus the panel, not the confirm button: landing on a destructive action
    // means a stray Enter deletes the thing.
    panelRef.current?.focus();

    const { overflow } = document.body.style;
    document.body.style.overflow = 'hidden';

    // Captured now, not read in cleanup: by the time cleanup runs the ref may
    // point somewhere else (or nowhere), and focus would be restored to the
    // wrong element or silently dropped.
    const trigger = triggerRef.current;

    function onKeyDown(event: KeyboardEvent) {
      if (event.key === 'Escape') {
        setOpen(false);
        return;
      }
      if (event.key !== 'Tab') return;

      // Trap Tab inside the panel — without this, focus walks out into the page
      // behind the backdrop, which a screen-reader user cannot see is blocked.
      const focusable = panelRef.current?.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
      );
      if (!focusable || focusable.length === 0) return;
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    document.addEventListener('keydown', onKeyDown);
    return () => {
      document.removeEventListener('keydown', onKeyDown);
      document.body.style.overflow = overflow;
      // Return focus where it came from, or the page jumps to the top.
      trigger?.focus();
    };
  }, [open]);

  return (
    <>
      <button
        ref={triggerRef}
        type="button"
        onClick={() => setOpen(true)}
        className="inline-flex h-9 items-center rounded-md border border-destructive/40 bg-background px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
      >
        {triggerLabel}
      </button>

      {open ? (
        <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
          {/* Backdrop is its own element so a click on it closes, while a click
              inside the panel does not bubble out and close it too. */}
          <div
            className="absolute inset-0 bg-background/80 backdrop-blur-sm"
            onClick={() => setOpen(false)}
            aria-hidden
          />
          <div
            ref={panelRef}
            role="alertdialog"
            aria-modal="true"
            aria-labelledby="confirm-dialog-title"
            aria-describedby="confirm-dialog-body"
            tabIndex={-1}
            className="relative w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl outline-none"
          >
            <div className="flex items-start gap-3">
              <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-destructive/10">
                <AlertTriangle className="h-4 w-4 text-destructive" aria-hidden />
              </div>
              <div className="min-w-0">
                <h2 id="confirm-dialog-title" className="text-base font-semibold tracking-tight">
                  {title}
                </h2>
                <p id="confirm-dialog-body" className="mt-1.5 text-sm text-muted-foreground">
                  {body}
                </p>
              </div>
            </div>

            <div className="mt-6 flex justify-end gap-2">
              <button
                type="button"
                onClick={() => setOpen(false)}
                className="inline-flex h-9 items-center rounded-md border border-border bg-background px-3 text-sm font-medium transition-colors hover:bg-accent"
              >
                {cancelLabel}
              </button>
              <button
                type="button"
                onClick={() => {
                  setOpen(false);
                  onConfirm?.();
                }}
                className="inline-flex h-9 items-center rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
              >
                {confirmLabel}
              </button>
            </div>
          </div>
        </div>
      ) : null}
    </>
  );
}

Dependencies

  • lucide-react — npm package

Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.