Patterns
Confirming irreversible actions
Which actions need confirmation, what the confirmation says and where focus goes.
Delete and similar actions cannot be undone. This pattern defines three levels by impact, so frequent actions stay fast and dangerous actions are not done by accident.
When to use
Section titled “When to use”- For every action that cannot be undone, such as deleting a record, report or project.
- For bulk deletes and permission removals that affect many items.
- Do not ask for confirmation on reversible actions; offer undo instead.
| Impact | Approach |
|---|---|
| Reversible | No confirmation. Do the action and offer “Undo” in a Toast. |
| Irreversible, limited impact | An AlertDialog that names the object and the consequence. |
| Irreversible, wide impact | Type to confirm: the user types the name of the object inside an AlertDialog. |
Live example
Section titled “Live example”Press “Delete project”. Focus moves to “Cancel”. Type a wrong name and confirm: the dialog stays open and the field shows the error. Press Escape to close, and focus returns to the button.
import { AlertDialog, Button, Text, TextInput, useToast } from '@bigbang/ui';import { useEffect, useRef, useState } from 'react';import { type Lang, LangProvider } from '../lang';
const copy = { tr: { trigger: 'Projeyi sil', title: 'Projeyi kalıcı olarak silin', titleSimple: 'Raporu silin', consequence: 'Proje A ve içindeki 12 kayıt kalıcı olarak silinir.', consequenceSimple: 'Rapor 1 kalıcı olarak silinir. Bu işlem geri alınamaz.', projectName: 'Proje A', field: 'Onaylamak için proje adını yazın', mismatch: 'Proje adını Proje A olarak yazın.', confirm: 'Projeyi kalıcı olarak sil', triggerSimple: 'Raporu sil', confirmSimple: 'Raporu kalıcı olarak sil', triggerUndo: 'Notu sil', done: 'Proje A silindi', doneSimple: 'Rapor 1 silindi', doneUndo: 'Not 1 silindi', undo: 'Geri al', restored: 'Not 1 geri alındı', deletedState: 'Silindi.', }, en: { trigger: 'Delete project', title: 'Delete the project permanently', titleSimple: 'Delete the report', consequence: 'Project A and its 12 records are deleted permanently.', consequenceSimple: 'Report 1 is deleted permanently. This cannot be undone.', projectName: 'Project A', field: 'Type the project name to confirm', mismatch: 'Type the project name as Project A.', confirm: 'Delete project permanently', triggerSimple: 'Delete report', confirmSimple: 'Delete report permanently', triggerUndo: 'Delete note', done: 'Project A deleted', doneSimple: 'Report 1 deleted', doneUndo: 'Note 1 deleted', undo: 'Undo', restored: 'Note 1 restored', deletedState: 'Deleted.', },};
/** Simulated request time, so the busy state of the confirm button is visible. */export const DELETE_DELAY = 400;
type Level = 'type' | 'simple' | 'undo';
function ConfirmDeleteDemo({ lang, level }: { lang: Lang; level: Level }) { const t = copy[lang]; const toast = useToast(); const [deleted, setDeleted] = useState(false); const [value, setValue] = useState(''); const [error, setError] = useState(''); const triggerRef = useRef<HTMLButtonElement>(null); const inputRef = useRef<HTMLInputElement>(null); const deletedRef = useRef<HTMLDivElement>(null); const restoredRef = useRef(false);
// Focus the "Deleted." line after a delete, or the trigger after an undo; the old trigger is gone or new. useEffect(() => { if (deleted) deletedRef.current?.focus(); else if (restoredRef.current) { restoredRef.current = false; triggerRef.current?.focus(); } }, [deleted]);
const doneText = level === 'type' ? t.done : level === 'simple' ? t.doneSimple : t.doneUndo;
const remove = () => { setDeleted(true); toast.show({ title: doneText, tone: 'success', ...(level === 'undo' ? { action: { label: t.undo, onClick: () => { restoredRef.current = true; setDeleted(false); toast.show({ title: t.restored, tone: 'info' }); }, }, } : {}), }); };
const confirm = async () => { if (level === 'type' && value.trim() !== t.projectName) { setError(t.mismatch); inputRef.current?.focus(); // A rejected promise keeps the dialog open; the error is already in the field. throw new Error('name mismatch'); } await new Promise((resolve) => setTimeout(resolve, DELETE_DELAY)); remove(); };
if (deleted) { return ( <div ref={deletedRef} tabIndex={-1} className="outline-none focus-visible:focus-outline" > <Text tone="subtle">{t.deletedState}</Text> </div> ); }
const trigger = ( <Button ref={triggerRef} variant="tertiary" tone="danger" {...(level === 'undo' ? { onClick: remove } : {})}> {level === 'type' ? t.trigger : level === 'simple' ? t.triggerSimple : t.triggerUndo} </Button> );
// Reversible: no confirmation, the toast offers a real undo. if (level === 'undo') return <div>{trigger}</div>;
return ( <div> <AlertDialog trigger={trigger} tone="danger" title={level === 'type' ? t.title : t.titleSimple} description={level === 'type' ? t.consequence : t.consequenceSimple} confirmLabel={level === 'type' ? t.confirm : t.confirmSimple} onConfirm={confirm} onOpenChange={(open) => { if (!open) { setValue(''); setError(''); } }} > {level === 'type' ? ( <TextInput ref={inputRef} label={t.field} autoComplete="off" value={value} onValueChange={(next) => { setValue(next); setError(''); }} {...(error ? { error } : {})} /> ) : null} </AlertDialog> </div> );}
/** * Confirmation for an irreversible action. `type` asks the user to type the object name inside an AlertDialog (wide impact); * `simple` only names the object and the consequence (limited impact); `undo` skips the dialog and offers "Undo" in the toast. * Focus starts on Cancel, never on the destructive button, and returns to the trigger when the dialog closes. */export function ConfirmDelete({ lang = 'tr', level = 'type' }: { lang?: Lang; level?: Level }) { return ( <LangProvider lang={lang}> <div className="w-full max-w-md"> <ConfirmDeleteDemo lang={lang} level={level} /> </div> </LangProvider> );}At limited impact there is no typing step; the dialog names the object and the consequence.
import { AlertDialog, Button, Text, TextInput, useToast } from '@bigbang/ui';import { useEffect, useRef, useState } from 'react';import { type Lang, LangProvider } from '../lang';
const copy = { tr: { trigger: 'Projeyi sil', title: 'Projeyi kalıcı olarak silin', titleSimple: 'Raporu silin', consequence: 'Proje A ve içindeki 12 kayıt kalıcı olarak silinir.', consequenceSimple: 'Rapor 1 kalıcı olarak silinir. Bu işlem geri alınamaz.', projectName: 'Proje A', field: 'Onaylamak için proje adını yazın', mismatch: 'Proje adını Proje A olarak yazın.', confirm: 'Projeyi kalıcı olarak sil', triggerSimple: 'Raporu sil', confirmSimple: 'Raporu kalıcı olarak sil', triggerUndo: 'Notu sil', done: 'Proje A silindi', doneSimple: 'Rapor 1 silindi', doneUndo: 'Not 1 silindi', undo: 'Geri al', restored: 'Not 1 geri alındı', deletedState: 'Silindi.', }, en: { trigger: 'Delete project', title: 'Delete the project permanently', titleSimple: 'Delete the report', consequence: 'Project A and its 12 records are deleted permanently.', consequenceSimple: 'Report 1 is deleted permanently. This cannot be undone.', projectName: 'Project A', field: 'Type the project name to confirm', mismatch: 'Type the project name as Project A.', confirm: 'Delete project permanently', triggerSimple: 'Delete report', confirmSimple: 'Delete report permanently', triggerUndo: 'Delete note', done: 'Project A deleted', doneSimple: 'Report 1 deleted', doneUndo: 'Note 1 deleted', undo: 'Undo', restored: 'Note 1 restored', deletedState: 'Deleted.', },};
/** Simulated request time, so the busy state of the confirm button is visible. */export const DELETE_DELAY = 400;
type Level = 'type' | 'simple' | 'undo';
function ConfirmDeleteDemo({ lang, level }: { lang: Lang; level: Level }) { const t = copy[lang]; const toast = useToast(); const [deleted, setDeleted] = useState(false); const [value, setValue] = useState(''); const [error, setError] = useState(''); const triggerRef = useRef<HTMLButtonElement>(null); const inputRef = useRef<HTMLInputElement>(null); const deletedRef = useRef<HTMLDivElement>(null); const restoredRef = useRef(false);
// Focus the "Deleted." line after a delete, or the trigger after an undo; the old trigger is gone or new. useEffect(() => { if (deleted) deletedRef.current?.focus(); else if (restoredRef.current) { restoredRef.current = false; triggerRef.current?.focus(); } }, [deleted]);
const doneText = level === 'type' ? t.done : level === 'simple' ? t.doneSimple : t.doneUndo;
const remove = () => { setDeleted(true); toast.show({ title: doneText, tone: 'success', ...(level === 'undo' ? { action: { label: t.undo, onClick: () => { restoredRef.current = true; setDeleted(false); toast.show({ title: t.restored, tone: 'info' }); }, }, } : {}), }); };
const confirm = async () => { if (level === 'type' && value.trim() !== t.projectName) { setError(t.mismatch); inputRef.current?.focus(); // A rejected promise keeps the dialog open; the error is already in the field. throw new Error('name mismatch'); } await new Promise((resolve) => setTimeout(resolve, DELETE_DELAY)); remove(); };
if (deleted) { return ( <div ref={deletedRef} tabIndex={-1} className="outline-none focus-visible:focus-outline" > <Text tone="subtle">{t.deletedState}</Text> </div> ); }
const trigger = ( <Button ref={triggerRef} variant="tertiary" tone="danger" {...(level === 'undo' ? { onClick: remove } : {})}> {level === 'type' ? t.trigger : level === 'simple' ? t.triggerSimple : t.triggerUndo} </Button> );
// Reversible: no confirmation, the toast offers a real undo. if (level === 'undo') return <div>{trigger}</div>;
return ( <div> <AlertDialog trigger={trigger} tone="danger" title={level === 'type' ? t.title : t.titleSimple} description={level === 'type' ? t.consequence : t.consequenceSimple} confirmLabel={level === 'type' ? t.confirm : t.confirmSimple} onConfirm={confirm} onOpenChange={(open) => { if (!open) { setValue(''); setError(''); } }} > {level === 'type' ? ( <TextInput ref={inputRef} label={t.field} autoComplete="off" value={value} onValueChange={(next) => { setValue(next); setError(''); }} {...(error ? { error } : {})} /> ) : null} </AlertDialog> </div> );}
/** * Confirmation for an irreversible action. `type` asks the user to type the object name inside an AlertDialog (wide impact); * `simple` only names the object and the consequence (limited impact); `undo` skips the dialog and offers "Undo" in the toast. * Focus starts on Cancel, never on the destructive button, and returns to the trigger when the dialog closes. */export function ConfirmDelete({ lang = 'tr', level = 'type' }: { lang?: Lang; level?: Level }) { return ( <LangProvider lang={lang}> <div className="w-full max-w-md"> <ConfirmDeleteDemo lang={lang} level={level} /> </div> </LangProvider> );}A reversible action has no dialog. “Delete note” happens at once; the Toast offers “Undo” and the undo really works.
import { AlertDialog, Button, Text, TextInput, useToast } from '@bigbang/ui';import { useEffect, useRef, useState } from 'react';import { type Lang, LangProvider } from '../lang';
const copy = { tr: { trigger: 'Projeyi sil', title: 'Projeyi kalıcı olarak silin', titleSimple: 'Raporu silin', consequence: 'Proje A ve içindeki 12 kayıt kalıcı olarak silinir.', consequenceSimple: 'Rapor 1 kalıcı olarak silinir. Bu işlem geri alınamaz.', projectName: 'Proje A', field: 'Onaylamak için proje adını yazın', mismatch: 'Proje adını Proje A olarak yazın.', confirm: 'Projeyi kalıcı olarak sil', triggerSimple: 'Raporu sil', confirmSimple: 'Raporu kalıcı olarak sil', triggerUndo: 'Notu sil', done: 'Proje A silindi', doneSimple: 'Rapor 1 silindi', doneUndo: 'Not 1 silindi', undo: 'Geri al', restored: 'Not 1 geri alındı', deletedState: 'Silindi.', }, en: { trigger: 'Delete project', title: 'Delete the project permanently', titleSimple: 'Delete the report', consequence: 'Project A and its 12 records are deleted permanently.', consequenceSimple: 'Report 1 is deleted permanently. This cannot be undone.', projectName: 'Project A', field: 'Type the project name to confirm', mismatch: 'Type the project name as Project A.', confirm: 'Delete project permanently', triggerSimple: 'Delete report', confirmSimple: 'Delete report permanently', triggerUndo: 'Delete note', done: 'Project A deleted', doneSimple: 'Report 1 deleted', doneUndo: 'Note 1 deleted', undo: 'Undo', restored: 'Note 1 restored', deletedState: 'Deleted.', },};
/** Simulated request time, so the busy state of the confirm button is visible. */export const DELETE_DELAY = 400;
type Level = 'type' | 'simple' | 'undo';
function ConfirmDeleteDemo({ lang, level }: { lang: Lang; level: Level }) { const t = copy[lang]; const toast = useToast(); const [deleted, setDeleted] = useState(false); const [value, setValue] = useState(''); const [error, setError] = useState(''); const triggerRef = useRef<HTMLButtonElement>(null); const inputRef = useRef<HTMLInputElement>(null); const deletedRef = useRef<HTMLDivElement>(null); const restoredRef = useRef(false);
// Focus the "Deleted." line after a delete, or the trigger after an undo; the old trigger is gone or new. useEffect(() => { if (deleted) deletedRef.current?.focus(); else if (restoredRef.current) { restoredRef.current = false; triggerRef.current?.focus(); } }, [deleted]);
const doneText = level === 'type' ? t.done : level === 'simple' ? t.doneSimple : t.doneUndo;
const remove = () => { setDeleted(true); toast.show({ title: doneText, tone: 'success', ...(level === 'undo' ? { action: { label: t.undo, onClick: () => { restoredRef.current = true; setDeleted(false); toast.show({ title: t.restored, tone: 'info' }); }, }, } : {}), }); };
const confirm = async () => { if (level === 'type' && value.trim() !== t.projectName) { setError(t.mismatch); inputRef.current?.focus(); // A rejected promise keeps the dialog open; the error is already in the field. throw new Error('name mismatch'); } await new Promise((resolve) => setTimeout(resolve, DELETE_DELAY)); remove(); };
if (deleted) { return ( <div ref={deletedRef} tabIndex={-1} className="outline-none focus-visible:focus-outline" > <Text tone="subtle">{t.deletedState}</Text> </div> ); }
const trigger = ( <Button ref={triggerRef} variant="tertiary" tone="danger" {...(level === 'undo' ? { onClick: remove } : {})}> {level === 'type' ? t.trigger : level === 'simple' ? t.triggerSimple : t.triggerUndo} </Button> );
// Reversible: no confirmation, the toast offers a real undo. if (level === 'undo') return <div>{trigger}</div>;
return ( <div> <AlertDialog trigger={trigger} tone="danger" title={level === 'type' ? t.title : t.titleSimple} description={level === 'type' ? t.consequence : t.consequenceSimple} confirmLabel={level === 'type' ? t.confirm : t.confirmSimple} onConfirm={confirm} onOpenChange={(open) => { if (!open) { setValue(''); setError(''); } }} > {level === 'type' ? ( <TextInput ref={inputRef} label={t.field} autoComplete="off" value={value} onValueChange={(next) => { setValue(next); setError(''); }} {...(error ? { error } : {})} /> ) : null} </AlertDialog> </div> );}
/** * Confirmation for an irreversible action. `type` asks the user to type the object name inside an AlertDialog (wide impact); * `simple` only names the object and the consequence (limited impact); `undo` skips the dialog and offers "Undo" in the toast. * Focus starts on Cancel, never on the destructive button, and returns to the trigger when the dialog closes. */export function ConfirmDelete({ lang = 'tr', level = 'type' }: { lang?: Lang; level?: Level }) { return ( <LangProvider lang={lang}> <div className="w-full max-w-md"> <ConfirmDeleteDemo lang={lang} level={level} /> </div> </LangProvider> );}| Rule | Value |
|---|---|
| Button label | Repeats the verb and the object: “Delete project permanently”. Never “Yes” or “OK”. |
| Default focus | On “Cancel” when the dialog opens (AlertDialog); never on the destructive button. |
| Cancel | Always available. Escape does the same and returns focus to the trigger. Pressing outside does not close the dialog. |
| Confirm button | Stays enabled (tone="danger"). On a name mismatch onConfirm rejects: the dialog stays open and an error shows under the field. The button is busy while the action runs. |
| Bulk delete | Includes the count: “Delete 12 records permanently”. |
| Reversible action | No confirmation; offer undo. |
| Result | Reported in a Toast: “Project A deleted”. Add “Undo” only when the action can really be undone. |
Behaviour
Section titled “Behaviour”- The user presses “Delete project”. The AlertDialog opens and focus moves to “Cancel”.
- For type to confirm, the user types the object name and presses the confirm button. On a mismatch
onConfirmrejects; the dialog stays open and the field error shows. - On a match the button becomes busy. When the action ends the dialog closes and a Toast says “Project A deleted”.
- Cancel or Escape closes the dialog, clears the typed text and returns focus to “Delete project”.
- A deleted object has no trigger left, so focus moves to the “Deleted.” line. For a reversible action, “Undo” returns focus to the trigger.
Accessibility
Section titled “Accessibility”- The dialog has
role="alertdialog", is named by its title and its description is read when it opens. Focus stays inside and the page behind is inert. - Focus starts on “Cancel” and returns to the trigger when the dialog closes, so keyboard users never lose their place.
- The error is tied to the field with
aria-invalidandaria-describedby(TextInput does this itself). - The result is announced by a Toast, so screen readers read it without interrupting.
- Type to confirm does not block pasting; the name stays visible and readable (WCAG 3.3.4).
Content
Section titled “Content”- The heading states the action: “Delete the project permanently”.
- The consequence text names the object and the effect: “Project A and its 12 records are deleted permanently.”
- The field label says what to type: “Type the project name to confirm”.
- Buttons start with a verb and have no full stop. The cancel button is “Cancel”.
Motion
Section titled “Motion”| Item | Motion | Preset |
|---|---|---|
| AlertDialog | The component’s own open and close motion | built in |
| Field error text | 150 ms fade, 4 px drop | motion.fieldMessage |
| Toast | The component’s own enter and exit motion | built in |
| Buttons | 0.97 scale on press | motion.press |
With reduced motion, movement is removed and only the fade stays.
Components used
Section titled “Components used”AlertDialog, Toast, Button, TextInput, Text. Related pattern: Forms and validation.