Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    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.

    • 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.

    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.

    Live example, StorybookOpen in Storybook
    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.

    Live example, StorybookOpen in Storybook
    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.

    Live example, StorybookOpen in Storybook
    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.
    DoThe button repeats the verb and the object.
    Don'tA generic label does not say what is deleted.
    1. The user presses “Delete project”. The AlertDialog opens and focus moves to “Cancel”.
    2. For type to confirm, the user types the object name and presses the confirm button. On a mismatch onConfirm rejects; the dialog stays open and the field error shows.
    3. On a match the button becomes busy. When the action ends the dialog closes and a Toast says “Project A deleted”.
    4. Cancel or Escape closes the dialog, clears the typed text and returns focus to “Delete project”.
    5. A deleted object has no trigger left, so focus moves to the “Deleted.” line. For a reversible action, “Undo” returns focus to the trigger.
    • 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-invalid and aria-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).
    • 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”.
    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.

    AlertDialog, Toast, Button, TextInput, Text. Related pattern: Forms and validation.