Type to Confirm
Overlays

Type to Confirm

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.

Install

npx shadcn@latest add @paragon/type-to-confirm

Also installs: button, dialog, input

type-to-confirm.tsx

"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>
  );
}