Skip to content
cennge-ui
GitHubv0.3.1, changelog

AlertDialog

A question before something hard to undo. It waits for an answer, and for the action to finish.

Danger zone

Archive this repository

Read-only until you unarchive it.

Delete this repository

Issues, releases and the wiki go with it.

  1. Archive: focus starts on Cancel. A click outside does nothing; Escape is the same as Cancel.
  2. Delete: focus starts in the field, and the button unlocks once the name is typed.
  3. While it deletes, the button spins and keeps keyboard focus; Cancel and Escape do nothing until it is done.
Live: the component itself, not a picture of it.

Field marks

Import
import { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogAction, AlertDialogCancel } from "cennge-ui";
Size
4.0 KB gzip for this import, its dependencies aside
Pattern
WAI-ARIA alert dialog: role="alertdialog", named by its title, described by its description
Keyboard
Tab Shift Tab Esc
State
Controlled with isOpen and onOpenChange, or uncontrolled
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Async actions
A promise from onAction keeps it open and busy until it settles
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: alert-dialog, dialog-scroll

When to use#

Ask with an AlertDialog before an action that destroys something or is hard to take back: deleting, archiving, transferring. It interrupts on purpose. There is no close button, a click outside does nothing, and focus starts on Cancel, so a stray Enter cancels rather than deletes.

When the action can be undone, it is often kinder not to ask at all: do it, and offer Undo in a toast.

Similar#

  • Dialog holds a task rather than a question: it has a close button, and a click on the backdrop closes it.
  • Toast with an action offers to undo after the fact instead of asking first.

Confirm, then act#

onAction runs when the action button is pressed, then the dialog closes. Cancel and Escape close it without running anything. Buttons named after what they do, "Keep editing" and "Discard", read faster than "No" and "Yes".

discard.tsx
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
  Button,
  toast,
} from "cennge-ui";

export default function DiscardDraft() {
  return (
    <AlertDialog>
      <AlertDialogTrigger>
        <Button variant="flat">Discard draft</Button>
      </AlertDialogTrigger>
      <AlertDialogContent size="sm">
        <AlertDialogHeader>
          <AlertDialogTitle>Discard the draft?</AlertDialogTitle>
          <AlertDialogDescription>The title and notes of cennge-ui 0.3.2 are lost. Published releases stay as they are.</AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Keep editing</AlertDialogCancel>
          <AlertDialogAction color="danger" onAction={() => toast("Draft discarded")}>
            Discard
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

A request that can fail#

Return a promise from onAction and the dialog stays open until it settles. Meanwhile the action shows a spinner and sets aria-busy, Cancel is disabled, Escape does nothing, and another press does not send the request twice. When the promise resolves the dialog closes; when it rejects the dialog stays open, ready for another try, and showing the error is up to you. This request fails every other time.

retry.tsx
import { useRef, useState } from "react";
import {
  Alert,
  AlertDialog,
  AlertDialogAction,
  AlertDialogBody,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
  Button,
  toast,
} from "cennge-ui";

export default function DeletePreview() {
  const [error, setError] = useState<string | null>(null);
  const attempts = useRef(0);

  // Stands in for the real request: every other attempt fails.
  function requestDelete() {
    attempts.current += 1;
    const fails = attempts.current % 2 === 1;
    return new Promise<void>((resolve, reject) =>
      setTimeout(() => (fails ? reject(new Error("The deploy service did not answer.")) : resolve()), 1500),
    );
  }

  async function deletePreview() {
    setError(null);
    try {
      await requestDelete();
      toast.success("Preview deleted", { description: "spredo-crypto-screener" });
    } catch (reason) {
      setError(reason instanceof Error ? reason.message : "Something went wrong.");
      throw reason; // a rejected promise keeps the dialog open
    }
  }

  return (
    <AlertDialog onOpenChange={(isOpen) => !isOpen && setError(null)}>
      <AlertDialogTrigger>
        <Button variant="flat" color="danger">
          Delete preview
        </Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Delete the preview of spredo-crypto-screener?</AlertDialogTitle>
          <AlertDialogDescription>Its link stops working. The branch and its commits stay.</AlertDialogDescription>
        </AlertDialogHeader>
        {error ? (
          <AlertDialogBody>
            <Alert color="danger" title="The preview is still there" description={`${error} Try again or cancel.`} />
          </AlertDialogBody>
        ) : null}
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction color="danger" onAction={deletePreview}>
            {error ? "Try again" : "Delete preview"}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

Type the name to confirm#

For the worst cases, ask for the name. onOpenAutoFocus with event.preventDefault() starts focus in the field instead of on Cancel, and isDisabled keeps the action locked until the text matches. onOpenChange clears the field when the dialog closes.

type-to-confirm.tsx
import { useRef, useState } from "react";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogBody,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
  Button,
  Input,
  toast,
} from "cennge-ui";

const repository = "Cennge/playwright-msi-scraper";

export default function DeleteRepository() {
  const [confirmation, setConfirmation] = useState("");
  const field = useRef<HTMLInputElement>(null);

  return (
    <AlertDialog onOpenChange={(isOpen) => !isOpen && setConfirmation("")}>
      <AlertDialogTrigger>
        <Button color="danger">Delete repository</Button>
      </AlertDialogTrigger>
      <AlertDialogContent
        onOpenAutoFocus={(event) => {
          event.preventDefault();
          field.current?.focus();
        }}
      >
        <AlertDialogHeader>
          <AlertDialogTitle>Delete {repository}?</AlertDialogTitle>
          <AlertDialogDescription>Issues, releases and the wiki will be deleted for good.</AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogBody>
          <Input ref={field} label={`Type ${repository} to confirm`} value={confirmation} onValueChange={setConfirmation} />
        </AlertDialogBody>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction
            color="danger"
            isDisabled={confirmation !== repository}
            onAction={() => toast.success("Repository deleted", { description: repository })}
          >
            Delete
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

Accessibility#

  • The dialog has role="alertdialog". Screen readers read its title and its description as it opens, so give it both.
  • Focus starts on Cancel, the harmless answer, and Tab and Shift+Tab stay inside. Escape is the same as Cancel. On close, focus goes back to the button that opened the dialog.
  • While an action runs, its button is aria-disabled rather than disabled, so keyboard focus stays on it instead of falling to the page. If the action fails, focus is still there for another try. The end-to-end tests press Enter on it and check both.
  • Below 640px wide the footer stacks its buttons in reverse order, so the action, written after Cancel, sits on top; from 640px they sit in a row at the right, the action last.
  • The end-to-end tests run in Chromium, Firefox and WebKit, on desktop and phone profiles: focus on Cancel and still inside after six Tabs, Escape back to the trigger, a click or a tap outside that changes nothing, the action pressed with the pointer and with Enter, Escape ignored while it runs, and axe in both themes. A Combobox opened inside scrolls as in a Dialog.

Styling#

classNames takes overlay and content. As in Dialog, the dimmed backdrop is the overlay's ::before, so your own backdrop takes the before: variant. size sets the width (sm 24rem, md 28rem, lg 32rem) and radius the corners. AlertDialogAction is a Button in the primary colour unless you pass color; AlertDialogCancel is a flat one.

Props#

AlertDialog#

PropTypeDefault
isOpen
boolean
โ€“
defaultOpen
boolean
โ€“
onOpenChange
(isOpen: boolean) => void
โ€“
children
React.ReactNode
โ€“

AlertDialogContent#

PropTypeDefault
size
"sm""md""lg"
"md"
radius
"none""sm""md""lg""full"
"lg"
onOpenAutoFocus
(event: Event) => voidRuns as the dialog opens and moves focus to Cancel. Call event.preventDefault() and focus another element to start elsewhere, e.g. a confirmation field.
โ€“
onCloseAutoFocus
(event: Event) => voidRuns as the dialog closes and returns focus; event.preventDefault() to send focus elsewhere.
โ€“
classNames
Partial<Record<AlertDialogSlots, string>>
โ€“
container
HTMLElement | nullMount the portal elsewhere (default: document.body).
โ€“

Also takes every attribute of <div>.

AlertDialogAction#

PropTypeDefault
onAction
() => unknownRuns on click, then the dialog closes. Return a promise to keep the dialog open with a spinner until it settles: it closes when the promise resolves and stays open when it rejects.
โ€“
color
"default""primary""secondary""success""warning""danger""info"
"default"
variant
"light""bordered""flat""faded""solid""shadow""ghost"
"solid"
size
"sm""md""lg"
"md"
radius
"none""sm""md""lg""full"
"md"
isIconOnly
booleanSquare button sized for a single icon. Give it an aria-label.
โ€“
fullWidth
boolean
โ€“
isDisabled
boolean
โ€“
startContent
React.ReactNode
โ€“
endContent
React.ReactNode
โ€“
spinner
React.ReactNodeReplaces the default spinner while isLoading.
โ€“

AlertDialogCancel#

PropTypeDefault
color
"default""primary""secondary""success""warning""danger""info"
"default"
isLoading
booleanShows a spinner in place of startContent and blocks interaction.
โ€“
variant
"light""bordered""flat""faded""solid""shadow""ghost"
"solid"
size
"sm""md""lg"
"md"
radius
"none""sm""md""lg""full"
"md"
isIconOnly
booleanSquare button sized for a single icon. Give it an aria-label.
โ€“
fullWidth
boolean
โ€“
isDisabled
boolean
โ€“
startContent
React.ReactNode
โ€“
endContent
React.ReactNode
โ€“
spinner
React.ReactNodeReplaces the default spinner while isLoading.
โ€“