Skip to content
txbrendan/ui

Alert Dialog

beta

A window over the page that asks people to confirm or cancel something serious, like a delete, before anything happens.

Preview

Effect

delete

leave

confirm

size

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/alert-dialog

Use it for

  • Confirming an action that cannot be undone, like deleting a project or leaving with unsaved changes.
  • Ask a question in AlertDialogTitle, and say what will happen in AlertDialogDescription.
  • Put Cancel first in AlertDialogFooter. It gets focus when the dialog opens, so Enter never confirms by accident.
  • Keep the footer to one row: quiet actions lead, and the confirm trails. Use short labels, so two or three buttons fit a 320px screen.
  • Name the main button with the action, like 'Delete project', not 'OK' or 'Yes'. Give it tone='danger' when it destroys data.
  • For an action that takes time, control open, keep the dialog open while it runs, and close it when it is done.
  • The alert dialog opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on AlertDialogPopup so the alert dialog and its backdrop keep the box's colors.

Avoid it for

  • A form or a task with several fields. Use a dialog.
  • A message that needs no answer. Use Alert on the page, or Toast.
  • Actions people can undo. Do it at once, and offer Undo for 8 seconds with toast.undoable().
  • Opening one when the page loads. People did not ask for it.

Props

  • AlertDialog

    Root. Holds the open state and renders no element. Takes open, defaultOpen, and onOpenChange.

    { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void }

  • open

    AlertDialog. Whether it is open, controlled. Use it with onOpenChange.

    boolean

  • defaultOpen

    AlertDialog. Whether it is open at the start, when you do not control it.

    boolean · default false

  • onOpenChange

    AlertDialog. Called with the new open state when it opens or closes.

    (open: boolean, details) => void

  • AlertDialogTrigger

    Part. The button that opens it. Pass render={<Button />} to use your own button. It keeps data-slot="alert-dialog-trigger", even from a server component.

    ComponentProps<"button"> & { render?: ReactElement }

  • AlertDialogPopup

    Part. The alert dialog, centered over a backdrop. It renders in a portal while open. It has no Close button in the corner.

    ComponentProps<"div">

  • size

    AlertDialogPopup. Most width: 24rem, 28rem, or 32rem. It never gets wider than the screen.

    "sm" | "md" | "lg" · default "md"

  • AlertDialogTitle

    Part. The question it asks, like 'Delete this project?'. An <h2> by default.

    ComponentProps<"h2">

  • AlertDialogDescription

    Part. What will happen. Screen readers read it when it opens.

    ComponentProps<"p">

  • AlertDialogFooter

    Part. One row of actions at the bottom, lined up at the end. It never wraps, so the confirm always sits last.

    Omit<ComponentProps<"div">, "color">

  • AlertDialogClose

    Part. A button that closes it, like Cancel, or the main action when it finishes at once.

    ComponentProps<"button"> & { render?: ReactElement }

  • initialFocus

    AlertDialogPopup. Where focus goes when it opens. By default, the first button, which should be Cancel.

    RefObject<HTMLElement | null> | boolean

  • container

    AlertDialogPopup. The element the alert dialog and its backdrop open in. By default, the end of the page body, so no parent can clip them. Pass an element, or a ref itself (not ref.current), to open them inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no alert dialog, but still locks page scroll until it closes.

    HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null

  • effect

    AlertDialogPopup. How the alert dialog and its backdrop open and close. rise fades in and rises a little. grow fades in and grows in place, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none.

    "rise" | "grow" · default "rise"

  • className

    AlertDialogPopup. Extra classes. Later classes win, so className='max-w-xl' replaces the width.

    string

Accessibility

  • The popup has role="alertdialog". AlertDialogTitle names it, and AlertDialogDescription describes it, so screen readers read both when it opens.
  • While it is open, the rest of the page is hidden from screen readers with aria-hidden and cannot be scrolled or reached.
  • Opening it moves focus to the first button. Put Cancel first, so the safe choice has focus.
  • Tab stays inside until it closes, even when pressed fast. Then focus goes back to the trigger.
  • Escape closes it, as Cancel does. A click on the backdrop does not, so people answer on purpose.
  • There is no Close button in the corner. Keep a Cancel button, since touch screen reader users cannot press Escape.
  • The theme tests hold dialog text and muted text to 4.5:1 on the popover color, in light and dark.

Examples

Confirm a delete (needs @txbrendan/button)

"use client";

import { Button } from "@/components/ui/button";

function DeleteProject({ onDelete }: { onDelete: () => void }) {
  return (
    <AlertDialog>
      <AlertDialogTrigger render={<Button tone="danger" emphasis="soft" />}>Delete project</AlertDialogTrigger>
      <AlertDialogPopup>
        <AlertDialogTitle>Delete this project?</AlertDialogTitle>
        <AlertDialogDescription>This deletes 24 files and 3 deploys. You cannot undo it.</AlertDialogDescription>
        <AlertDialogFooter>
          <AlertDialogClose render={<Button emphasis="outline" />}>Cancel</AlertDialogClose>
          <AlertDialogClose render={<Button tone="danger" />} onClick={onDelete}>
            Delete project
          </AlertDialogClose>
        </AlertDialogFooter>
      </AlertDialogPopup>
    </AlertDialog>
  );
}

Wait for the action, then close (needs @txbrendan/button)

"use client";

import { useState } from "react";

import { Button } from "@/components/ui/button";

function RemoveMember({ name, remove }: { name: string; remove: () => Promise<void> }) {
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  return (
    <AlertDialog open={open} onOpenChange={(next) => !loading && setOpen(next)}>
      <AlertDialogTrigger render={<Button emphasis="outline" />}>Remove {name}</AlertDialogTrigger>
      <AlertDialogPopup size="sm">
        <AlertDialogTitle>Remove {name} from the team?</AlertDialogTitle>
        <AlertDialogDescription>They lose access to every project at once.</AlertDialogDescription>
        <AlertDialogFooter>
          <AlertDialogClose render={<Button emphasis="outline" disabled={loading} />}>Cancel</AlertDialogClose>
          <Button
            tone="danger"
            loading={loading}
            onClick={async () => {
              setLoading(true);
              await remove();
              setLoading(false);
              setOpen(false);
            }}
          >
            Remove
          </Button>
        </AlertDialogFooter>
      </AlertDialogPopup>
    </AlertDialog>
  );
}

Leave without saving (needs @txbrendan/button)

import { Button } from "@/components/ui/button";

<AlertDialog>
  <AlertDialogTrigger render={<Button emphasis="ghost" />}>Back to projects</AlertDialogTrigger>
  <AlertDialogPopup>
    <AlertDialogTitle>Leave without saving?</AlertDialogTitle>
    <AlertDialogDescription>Your changes to this page will be lost.</AlertDialogDescription>
    <AlertDialogFooter>
      <AlertDialogClose render={<Button emphasis="outline" />}>Keep editing</AlertDialogClose>
      <AlertDialogClose render={<Button tone="danger" />}>Leave</AlertDialogClose>
    </AlertDialogFooter>
  </AlertDialogPopup>
</AlertDialog>

Grows in place (needs @txbrendan/button)

import { Button } from "@/components/ui/button";

<AlertDialog>
  <AlertDialogTrigger render={<Button emphasis="outline" />}>Discard draft</AlertDialogTrigger>
  <AlertDialogPopup effect="grow">
    <AlertDialogTitle>Discard this draft?</AlertDialogTitle>
    <AlertDialogDescription>You cannot undo it.</AlertDialogDescription>
    <AlertDialogFooter>
      <AlertDialogClose render={<Button emphasis="outline" />}>Cancel</AlertDialogClose>
      <AlertDialogClose render={<Button tone="danger" />}>Discard</AlertDialogClose>
    </AlertDialogFooter>
  </AlertDialogPopup>
</AlertDialog>