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.