Delete dialog that arms only when the resource name is typed exactly — a letter-by-letter underline fills with the matched prefix and turns destructive on a typo.
npx shadcn@latest add @paragon/type-to-confirmAlso installs: button, dialog, input
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/paragon/ui/dialog";
import { Input } from "@/registry/paragon/ui/input";
export interface TypeToConfirmProps {
/** The element that opens the dialog — rendered asChild. */
children?: React.ReactNode;
/** The exact string the user must type. */
resourceName?: string;
title?: React.ReactNode;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Compare case-sensitively (default true — resource names usually are). */
caseSensitive?: boolean;
/** Runs when the name matches and the user confirms. */
onConfirm?: () => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
function commonPrefixLength(a: string, b: string): number {
const max = Math.min(a.length, b.length);
let i = 0;
while (i < max && a[i] === b[i]) i++;
return i;
}
/**
* Destructive confirmation that only arms once the user has typed the
* resource's exact name. A hairline underline inside the field fills
* letter-by-letter as the typed prefix matches — drift off the name and the
* fill parks and turns destructive, so the user sees exactly where the typo
* is. The confirm button stays disabled until the match is exact; Enter
* confirms once armed. Match state is mirrored to a polite live region.
*/
export function TypeToConfirm({
children,
resourceName = "acme-production",
title = "Delete resource",
description,
confirmLabel = "Delete",
cancelLabel = "Cancel",
caseSensitive = true,
onConfirm,
open: openProp,
defaultOpen,
onOpenChange,
}: TypeToConfirmProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
defaultOpen ?? false,
);
const open = openProp ?? uncontrolledOpen;
const setOpen = React.useCallback(
(next: boolean) => {
setUncontrolledOpen(next);
onOpenChange?.(next);
},
[onOpenChange],
);
const [value, setValue] = React.useState("");
const inputId = React.useId();
// Reset the field every time the dialog opens — a stale match is a footgun.
React.useEffect(() => {
if (open) setValue("");
}, [open]);
const target = caseSensitive ? resourceName : resourceName.toLowerCase();
const typed = caseSensitive ? value : value.toLowerCase();
const matched = commonPrefixLength(typed, target);
const diverged = value.length > 0 && matched < typed.length;
const complete = typed === target && target.length > 0;
const progress = target.length === 0 ? 0 : matched / target.length;
const confirm = () => {
if (!complete) return;
setOpen(false);
onConfirm?.();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description ?? (
<>
This action cannot be undone. It permanently deletes the
resource and everything inside it.
</>
)}
</DialogDescription>
</DialogHeader>
<div>
<label
htmlFor={inputId}
className="mb-1.5 block text-sm text-muted-foreground"
>
Type{" "}
<code className="rounded bg-secondary px-1 py-0.5 font-mono text-[0.85em] font-medium text-secondary-foreground select-all">
{resourceName}
</code>{" "}
to confirm
</label>
<div className="relative">
<Input
id={inputId}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
confirm();
}
}}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={resourceName}
className="pr-14 font-mono text-[13px]"
aria-invalid={diverged || undefined}
/>
{/* Progress underline: fills letter-by-letter along the matched
prefix. transform-only, interruptible, parks on a typo. */}
<span
aria-hidden
className="pointer-events-none absolute inset-x-2 bottom-0 h-0.5 overflow-hidden rounded-full"
>
<span
className={cn(
"absolute inset-0 origin-left rounded-full transition-[transform,background-color] duration-150 ease-[var(--ease-out)]",
complete
? "bg-success"
: diverged
? "bg-destructive"
: "bg-primary",
)}
style={{ transform: `scaleX(${progress})` }}
/>
</span>
<span
aria-hidden
className={cn(
"pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 font-mono text-[11px] tabular-nums transition-colors duration-150",
complete
? "text-success"
: diverged
? "text-destructive"
: "text-muted-foreground",
)}
>
{matched}/{target.length}
</span>
</div>
<p aria-live="polite" className="sr-only">
{complete
? "Name matches. Confirmation enabled."
: diverged
? "Name does not match."
: ""}
</p>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">{cancelLabel}</Button>
</DialogClose>
<Button variant="destructive" disabled={!complete} onClick={confirm}>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}