Skip to main content

Confirm

The "are you sure" moment, in one piece. A Dialog that behaves as an alert, a cancel and an action button, an optional type-to-confirm guard, and a hook that turns it into a promise.

1'use client';
2
3import { useState } from 'react';
4import { Confirm } from '@/components/ui/confirm';
5
6function TrashIcon({ size = 16 }: { size?: number }) {
7 return (
8 <svg
9 xmlns="http://www.w3.org/2000/svg"
10 width={size}
11 height={size}
12 viewBox="0 0 24 24"
13 fill="none"
14 stroke="currentColor"
15 strokeWidth={1.5}
16 strokeLinecap="round"
17 strokeLinejoin="round"
18 aria-hidden="true"
19 >
20 <path d="M3 6h18" />
21 <path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
22 <path d="M19 6l-1 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1L5 6" />
23 <path d="M10 11v6" />
24 <path d="M14 11v6" />
25 </svg>
26 );
27}
28
29export function Default() {
30 const [result, setResult] = useState<string | null>(null);
31
32 return (
33 <div className="flex w-full max-w-md flex-col items-start gap-3">
34 <Confirm
35 title="Delete project"
36 description="The repository, its deployments and every environment variable go with it. This cannot be undone."
37 icon={<TrashIcon />}
38 variant="destructive"
39 confirmText="Delete project"
40 onConfirm={() =>
41 new Promise<void>((resolve) => {
42 setTimeout(() => {
43 setResult('Deleted.');
44 resolve();
45 }, 900);
46 })
47 }
48 onCancel={() => setResult('Kept it.')}
49 >
50 <Confirm.Trigger className="border-border hover:bg-muted rounded-md border px-3 py-1.5 text-sm transition-colors">
51 Delete project
52 </Confirm.Trigger>
53 </Confirm>
54 {result && <p className="text-muted-foreground text-xs">{result}</p>}
55 </div>
56 );
57}

Installation

pnpm dlx nachui add confirm

Anatomy

1import { Confirm } from '@/components/ui/confirm';
1<Confirm
2 title="Delete project"
3 description="This cannot be undone."
4 variant="destructive"
5 confirmText="Delete"
6 onConfirm={deleteProject}
7>
8 <Confirm.Trigger>Delete</Confirm.Trigger>
9</Confirm>
With only a trigger as child the panel lays itself out: icon if you gave one, title, description, the text guard when requireText is set, then Cancel and the action in the footer. The panel has role="alertdialog", so screen readers announce it as something that needs an answer, and Escape and the overlay count as cancel.
Pass a Confirm.Content to rearrange the footer or add something of yours between the description and the buttons.
1<Confirm title="Sign out everywhere?" onConfirm={signOutAll}>
2 <Confirm.Trigger>Sign out</Confirm.Trigger>
3 <Confirm.Content>
4 <p className="text-muted-foreground text-sm">Your other 3 sessions end now.</p>
5 <Dialog.Footer>
6 <Confirm.Cancel>Keep them</Confirm.Cancel>
7 <Confirm.Action>Sign out</Confirm.Action>
8 </Dialog.Footer>
9 </Confirm.Content>
10</Confirm>

Composition

A hybrid is visible composition. Open confirm.tsx and these are the elements it is made of, in this order:
Confirm
├── Dialog
│ ├── Confirm.Trigger → Dialog.Trigger
│ └── Confirm.Content → Dialog.Content (role="alertdialog")
│ ├── Dialog.Header
│ │ ├── Dialog.Title
│ │ └── Dialog.Description
│ ├── Input (only with requireText)
│ └── Dialog.Footer
│ ├── Confirm.Cancel → Button (outline)
│ └── Confirm.Action → Button (default or destructive)
└── ConfirmProvider + useConfirm()

Variants

variant="destructive" paints the action button with the destructive tokens and moves the initial focus to Cancel, so a stray Enter does not delete anything. The default variant focuses the action instead, because there the fast path is the right one.

Async confirm

Return a promise from onConfirm and the panel waits for it. The action shows the spinner, both buttons disable, Escape and the overlay stop closing it. It closes when the promise resolves and stays open, buttons back on, when it rejects, so the reader can try again or bail out.
1<Confirm
2 title="Delete project"
3 variant="destructive"
4 onConfirm={async () => {
5 await api.projects.delete(id);
6 }}
7/>

Type to confirm

requireText adds an input under the description and keeps the action disabled until what the reader typed matches exactly. Enter inside the input confirms once it matches.
1'use client';
2
3import { useState } from 'react';
4import { Confirm } from '@/components/ui/confirm';
5
6const PROJECT = 'ignaciofigueroa.dev';
7
8export function WithText() {
9 const [result, setResult] = useState<string | null>(null);
10
11 return (
12 <div className="flex w-full max-w-md flex-col items-start gap-3">
13 <Confirm
14 title={`Transfer ${PROJECT}`}
15 description="Ownership moves to the other team and you lose access to the settings."
16 variant="destructive"
17 confirmText="Transfer"
18 requireText={PROJECT}
19 onConfirm={() => setResult(`${PROJECT} transferred.`)}
20 onCancel={() => setResult('Nothing changed.')}
21 >
22 <Confirm.Trigger className="border-border hover:bg-muted rounded-md border px-3 py-1.5 text-sm transition-colors">
23 Transfer project
24 </Confirm.Trigger>
25 </Confirm>
26 {result && <p className="text-muted-foreground text-xs">{result}</p>}
27 </div>
28 );
29}

The hook

Wrap the app, or the part of it that asks questions, in ConfirmProvider. Then useConfirm() gives you a function that opens the panel and resolves true on confirm and false on cancel, Escape or the overlay. Calls queue up, one panel at a time.
1const confirm = useConfirm();
2
3async function remove(id: string) {
4 const ok = await confirm({
5 title: 'Delete this comment?',
6 variant: 'destructive',
7 confirmText: 'Delete',
8 });
9 if (ok) await api.comments.delete(id);
10}

Three drafts in the archive.

1'use client';
2
3import { useState } from 'react';
4import { ConfirmProvider, useConfirm } from '@/components/ui/confirm';
5
6function ArchiveButton() {
7 const confirm = useConfirm();
8 const [status, setStatus] = useState('Three drafts in the archive.');
9
10 const archive = async () => {
11 const ok = await confirm({
12 title: 'Archive the draft?',
13 description: 'It leaves the list but stays searchable.',
14 confirmText: 'Archive',
15 });
16 setStatus(ok ? 'Archived. Four drafts in the archive.' : 'Still in the list.');
17 };
18
19 return (
20 <div className="flex flex-col items-start gap-3">
21 <button
22 type="button"
23 onClick={() => void archive()}
24 className="border-border hover:bg-muted rounded-md border px-3 py-1.5 text-sm transition-colors"
25 >
26 Archive draft
27 </button>
28 <p className="text-muted-foreground text-xs">{status}</p>
29 </div>
30 );
31}
32
33export function Hook() {
34 return (
35 <ConfirmProvider>
36 <div className="w-full max-w-md">
37 <ArchiveButton />
38 </div>
39 </ConfirmProvider>
40 );
41}

API Reference

Confirm

PropTypeDefaultDescription
titleReactNode-The question, also the accessible name
descriptionReactNode-What happens if they say yes
iconReactNode-Rendered in a circle above the title
variant'default' | 'destructive''default'Tone of the action and where the focus lands
confirmTextstring'Confirm'Label of the action button
cancelTextstring'Cancel'Label of the cancel button
requireTextstring-Text the reader must type before confirming
labelsPartial<{ confirm; cancel; typeToConfirm }>EnglishEvery visible string, typeToConfirm is a function
openboolean-Controlled open state
defaultOpenbooleanfalseOpen state when uncontrolled
onOpenChange(open: boolean) => void-Called when the open state changes
onConfirm() => void | Promise<void>-Called by the action; a promise keeps it pending
onCancel() => void-Called by cancel, Escape and the overlay
classNamestring-Classes for the default panel
childrenReactNode-A Confirm.Trigger and, optionally, a Confirm.Content

Confirm.Trigger

PropTypeDefaultDescription
asChildbooleanfalseUse the child element as the trigger
classNamestring-Additional CSS classes

Confirm.Content

PropTypeDefaultDescription
childrenReactNodeCancel and ActionReplaces the footer; header and guard stay
classNamestring-Additional CSS classes

Confirm.Cancel

PropTypeDefaultDescription
childrenReactNodecancelTextLabel
classNamestring-Additional CSS classes
...--Any Button prop except variant

Confirm.Action

PropTypeDefaultDescription
childrenReactNodeconfirmTextLabel
classNamestring-Additional CSS classes
...--Any Button prop except variant

useConfirm

SignatureDescription
useConfirm(): (options: ConfirmOptions) => Promise<boolean>Opens a panel, resolves true on confirm and false otherwise
ConfirmOptions takes title, description, icon, variant, confirmText, cancelText, requireText and labels, with the same meaning as the props above. It needs a ConfirmProvider above it.
Found something to improve?

Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.

Create an Issue