Confirmation dialog that unlocks a destructive action only after the exact name is typed, flagging a typo as it happens.
StopsWrong repo deleted on reflex
"use client"
import * as React from "react"
import { Trash2 } from "lucide-react"
import { TypeToConfirm } from "@/components/ui/type-to-confirm"
export function Demo() {
const [open, setOpen] = React.useState(false)
const [deleted, setDeleted] = React.useState(false)
return (
<div className="flex flex-col items-center gap-3">
<button
type="button"
onClick={() => (deleted ? setDeleted(false) : setOpen(true))}
className="inline-flex h-9 items-center gap-2 rounded-md border border-destructive/40 bg-background px-4 text-sm font-medium text-destructive shadow-xs outline-none hover:bg-destructive/10 focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
>
<Trash2 aria-hidden className="size-4" />
{deleted ? "Restore demo" : "Delete repository"}
</button>
<p aria-live="polite" className="text-center text-xs text-muted-foreground">
{deleted ? "acme/website was deleted." : "Try typing acme/webiste: the button stays locked."}
</p>
<TypeToConfirm
open={open}
onOpenChange={setOpen}
phrase="acme/website"
description="This permanently deletes the repository, its wiki, issues and comments."
confirmLabel="Delete this repository"
onConfirm={async () => {
await new Promise((resolve) => setTimeout(resolve, 800))
setDeleted(true)
}}
/>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uitype-to-confirm.tsx217 lines"use client" // Bollard UI — type-to-confirm · MIT licence · https://bollard-ui.vercel.app/components/type-to-confirm import * as React from "react" import { CircleAlert, LoaderCircle, TriangleAlert } from "lucide-react" import { cn } from "@/lib/utils" /** How many leading characters of `typed` match `phrase` exactly. */ export function matchedLength(typed: string, phrase: string) { let i = 0 while (i < typed.length && i < phrase.length && typed[i] === phrase[i]) i++ return i } export interface TypeToConfirmProps extends Omit<React.ComponentProps<"dialog">, "open" | "title"> { /** Controlled open state. */ open?: boolean /** Initial open state when uncontrolled. */ defaultOpen?: boolean /** Called when the dialog opens or closes. */ onOpenChange?: (open: boolean) => void /** The exact phrase the user has to type, e.g. the repository name. */ phrase: string /** Dialog title. */ title?: React.ReactNode /** Dialog description: what will be deleted and what can't be undone. */ description?: React.ReactNode /** Runs when the user confirms. Return a promise to show a pending state; a rejection keeps the dialog open with its message. */ onConfirm: () => void | Promise<void> /** Label for the input. Receives the phrase, already styled. */ instruction?: (phrase: React.ReactNode) => React.ReactNode /** Shown under the input once the typing stops matching. */ mismatchHint?: string /** Label of the confirm button. */ confirmLabel?: React.ReactNode /** Confirm label while onConfirm runs. */ pendingLabel?: React.ReactNode /** Label of the Cancel button. */ cancelLabel?: React.ReactNode /** Fallback when `onConfirm` rejects without an Error message. */ errorMessage?: string } const button = "inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium whitespace-nowrap shadow-xs outline-none transition-[color,background-color,box-shadow] focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50" /** * GitHub-style "danger zone" dialog: the confirm button stays locked until * the user types the exact phrase, so a reflexive Enter can't delete the * wrong thing. The phrase lights up character by character as it matches and * a typo is flagged the moment it happens. Built on the native <dialog>, so * focus trapping, Escape and the top layer come from the browser. */ export function TypeToConfirm({ open: openProp, defaultOpen = false, onOpenChange, phrase, title = "Are you absolutely sure?", description = "This action cannot be undone.", onConfirm, instruction = (p) => <>Type {p} to confirm.</>, mismatchHint = "That doesn't match yet. Check for typos.", confirmLabel = "Delete", pendingLabel = "Deleting…", cancelLabel = "Cancel", errorMessage = "That didn't go through. Try again.", className, ...props }: TypeToConfirmProps) { const dialogRef = React.useRef<HTMLDialogElement>(null) const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) const open = openProp ?? uncontrolledOpen const [typed, setTyped] = React.useState("") const [pending, setPending] = React.useState(false) const [error, setError] = React.useState<string>() const id = React.useId() const setOpen = (next: boolean) => { if (openProp === undefined) setUncontrolledOpen(next) onOpenChange?.(next) } React.useEffect(() => { const dialog = dialogRef.current if (!dialog) return if (open && !dialog.open) { setTyped("") setError(undefined) dialog.showModal() } else if (!open && dialog.open) { dialog.close() } }, [open]) const value = typed.trim() const matches = value === phrase const matched = matchedLength(value, phrase) const mismatch = value.length > 0 && matched < value.length const handleConfirm = async (event: React.FormEvent) => { event.preventDefault() if (!matches || pending) return setPending(true) setError(undefined) try { await onConfirm() setOpen(false) } catch (err) { setError(err instanceof Error && err.message ? err.message : errorMessage) } finally { setPending(false) } } return ( <dialog ref={dialogRef} data-slot="type-to-confirm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-description`} onClose={() => open && setOpen(false)} onCancel={(event) => pending && event.preventDefault()} // Clicking the backdrop (the dialog element itself) closes it. onClick={(event) => event.target === event.currentTarget && !pending && setOpen(false)} className={cn( "m-auto w-[calc(100%-2rem)] max-w-md rounded-xl border bg-background p-0 text-foreground shadow-xl backdrop:bg-black/50 backdrop:backdrop-blur-[2px]", className, )} {...props} > <form onSubmit={handleConfirm} className="flex flex-col gap-4 p-6"> <div className="flex gap-3"> <span className="grid size-9 shrink-0 place-items-center rounded-full bg-destructive/10 text-destructive"> <TriangleAlert className="size-4.5" aria-hidden /> </span> <div className="min-w-0 space-y-1"> <h2 id={`${id}-title`} className="text-base font-semibold"> {title} </h2> <div id={`${id}-description`} className="text-sm text-muted-foreground"> {description} </div> </div> </div> <div> <label htmlFor={`${id}-input`} className="block text-sm"> {instruction( <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[13px] font-semibold break-all"> <span className="text-emerald-700 dark:text-emerald-400">{phrase.slice(0, matched)}</span> {phrase.slice(matched)} </code>, )} </label> <input id={`${id}-input`} autoFocus autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false} value={typed} disabled={pending} aria-invalid={mismatch || undefined} aria-describedby={`${id}-hint`} onChange={(event) => setTyped(event.target.value)} className="mt-2 h-9 w-full rounded-md border bg-transparent px-3 font-mono text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 aria-invalid:border-destructive dark:bg-input/30" /> <p id={`${id}-hint`} aria-live="polite" className="mt-1.5 min-h-4 text-xs text-destructive"> {mismatch && ( <span className="inline-flex items-center gap-1.5"> <CircleAlert aria-hidden className="size-3.5" /> {mismatchHint} </span> )} </p> </div> {error && ( <p role="alert" className="-mt-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"> {error} </p> )} <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <button type="button" disabled={pending} onClick={() => setOpen(false)} className={cn( button, "border bg-background hover:bg-accent dark:border-input dark:bg-input/30 dark:hover:bg-input/50", )} > {cancelLabel} </button> <button type="submit" // aria-disabled keeps the button discoverable; the submit handler ignores it until the phrase matches. aria-disabled={!matches || pending || undefined} aria-busy={pending || undefined} className={cn( button, "bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60 dark:hover:bg-destructive/70 aria-busy:cursor-progress", !matches && "cursor-not-allowed opacity-50 hover:bg-destructive dark:hover:bg-destructive/60", )} > {pending && <LoaderCircle className="size-4 animate-spin" aria-hidden />} {pending ? pendingLabel : confirmLabel} </button> </div> </form> </dialog> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { TypeToConfirm } from "@/components/ui/type-to-confirm"
<TypeToConfirm
open={open}
onOpenChange={setOpen}
phrase="acme/website"
description="This permanently deletes the repository, its wiki, issues and comments."
confirmLabel="Delete this repository"
onConfirm={async () => {
await new Promise((resolve) => setTimeout(resolve, 800))
setDeleted(true)
}}
/>What it catches
- Confirming before the exact phrase is typed
- A typo, flagged at the first mismatching character
- Failed confirms, which keep the dialog open with the error
- Repeat confirms while pending
Props
| Prop | Type | Default |
|---|---|---|
openControlled open state. | boolean | — |
defaultOpenInitial open state when uncontrolled. | boolean | false |
onOpenChangeCalled when the dialog opens or closes. | (open: boolean) => void | — |
phrase* (required)The exact phrase the user has to type, e.g. the repository name. | string | — |
titleDialog title. | React.ReactNode | "Are you absolutely sure?" |
descriptionDialog description: what will be deleted and what can't be undone. | React.ReactNode | "This action cannot be undone." |
onConfirm* (required)Runs when the user confirms. Return a promise to show a pending state; a rejection keeps the dialog open with its message. | () => void | Promise<void> | — |
instructionLabel for the input. Receives the phrase, already styled. | (phrase: React.ReactNode) => React.ReactNode | (p) => <>Type {p} to confirm.</> |
mismatchHintShown under the input once the typing stops matching. | string | "That doesn't match yet. Check for typos." |
confirmLabelLabel of the confirm button. | React.ReactNode | "Delete" |
pendingLabelConfirm label while onConfirm runs. | React.ReactNode | "Deleting…" |
cancelLabelLabel of the Cancel button. | React.ReactNode | "Cancel" |
errorMessageFallback when `onConfirm` rejects without an Error message. | string | "That didn't go through. Try again." |
* required. Also accepts the native props of <dialog> (className, aria-*, data-*…), passed through to it.
Accessibility
- Native <dialog> labelled and described by its title and description
- Input has a <label>, autoFocus, aria-invalid and a polite hint
- Confirm uses aria-disabled (stays discoverable) and aria-busy while pending
- Errors use role=alert
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
This is a UX guard rail. Enforce on the server: Authorize the destructive action server-side and confirm the target id there; the typed phrase is a UX guard only.
- Case-sensitive exact match after trimming leading and trailing spaces
- Typing a name proves attention, not authorization
- Make the phrase the real target (repo, env, account) so it names what's at stake
What it stops
Wrong repo deleted on reflex
How people describe it
Also known as
type to confirm · delete repository · delete project · github style · confirmation dialog · modal · danger zone
Field type