Overlays

Alert dialog

A modal confirmation that has to be answered, for actions that destroy data or cannot be undone.

Built on
<dialog>Invoker commandsclosedby<form method="dialog">autofocus
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
1.4 kB
Built from
Dialog

Delete the Larder workspace?

All 12 projects, their files and member access are removed for everyone. This cannot be undone.

Code
import { Button } from "@/components/equip/button";
import {
  AlertDialog,
  AlertDialogActions,
  AlertDialogDescription,
  AlertDialogTitle,
} from "@/components/equip/alert-dialog";

export default function Demo() {
  return (
    <>
      <Button variant="outline" command="show-modal" commandfor="demo-delete-workspace">
        Delete workspace
      </Button>
      <AlertDialog id="demo-delete-workspace" tone="danger">
        <AlertDialogTitle>Delete the Larder workspace?</AlertDialogTitle>
        <AlertDialogDescription>
          <p>All 12 projects, their files and member access are removed for everyone. This cannot be undone.</p>
        </AlertDialogDescription>
        <AlertDialogActions>
          <Button type="submit" variant="outline" value="cancel">
            Cancel
          </Button>
          <Button type="submit" variant="danger" value="delete">
            Delete workspace
          </Button>
        </AlertDialogActions>
      </AlertDialog>
    </>
  );
}

Examples

Danger with consequences

Spell out what is lost, and name the action on the button instead of a bare OK.

Revoke 3 production API keys?

Requests signed with these keys fail at once:

  • billing-worker, last used 2 minutes ago
  • ingest-eu, last used 1 hour ago
  • ci-deploy, last used yesterday
Code
import { Button } from "@/components/equip/button";
import {
  AlertDialog,
  AlertDialogActions,
  AlertDialogDescription,
  AlertDialogTitle,
} from "@/components/equip/alert-dialog";

export default function Example() {
  return (
    <>
      <Button variant="danger" command="show-modal" commandfor="example-revoke-keys">
        Revoke all keys
      </Button>
      <AlertDialog id="example-revoke-keys" tone="danger">
        <AlertDialogTitle>Revoke 3 production API keys?</AlertDialogTitle>
        <AlertDialogDescription>
          <p>Requests signed with these keys fail at once:</p>
          <ul>
            <li>billing-worker, last used 2 minutes ago</li>
            <li>ingest-eu, last used 1 hour ago</li>
            <li>ci-deploy, last used yesterday</li>
          </ul>
        </AlertDialogDescription>
        <AlertDialogActions>
          <Button type="submit" variant="outline" value="cancel">
            Keep keys
          </Button>
          <Button type="submit" variant="danger" value="revoke">
            Revoke 3 keys
          </Button>
        </AlertDialogActions>
      </AlertDialog>
    </>
  );
}

Reading the answer

Escape leaves returnValue as it was, so clear it after reading or a stale answer comes back next time.

Save changes to Q3 roadmap?

You edited 4 sections since the last save.

Answer: none yet
Code
"use client";

import { useState, type SyntheticEvent } from "react";
import { Button } from "@/components/equip/button";
import {
  AlertDialog,
  AlertDialogActions,
  AlertDialogDescription,
  AlertDialogTitle,
} from "@/components/equip/alert-dialog";

export default function Example() {
  const [answer, setAnswer] = useState("none yet");

  function handleClose(event: SyntheticEvent<HTMLDialogElement>) {
    const dialog = event.currentTarget;
    setAnswer(dialog.returnValue || "dismissed with Escape");
    dialog.returnValue = "";
  }

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
      <Button variant="outline" command="show-modal" commandfor="example-discard-draft">
        Close editor
      </Button>
      <AlertDialog id="example-discard-draft" onClose={handleClose}>
        <AlertDialogTitle>Save changes to Q3 roadmap?</AlertDialogTitle>
        <AlertDialogDescription>
          <p>You edited 4 sections since the last save.</p>
        </AlertDialogDescription>
        <AlertDialogActions>
          <Button type="submit" variant="ghost" value="cancel">
            Keep editing
          </Button>
          <Button type="submit" variant="outline" value="discard">
            Discard
          </Button>
          <Button type="submit" variant="solid" value="save">
            Save
          </Button>
        </AlertDialogActions>
      </AlertDialog>
      <output style={{ color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>Answer: {answer}</output>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
idstringnoneRequired. Trigger buttons point at it with commandfor.
tone"default" | "danger""default"danger puts a warning icon before the title, for actions that destroy data.
...restComponentProps<"dialog">noneEvery native dialog attribute except closedby, which is fixed to closerequest. Includes onClose and ref.
AlertDialogTitleComponentProps<"h2">noneThe question. The dialog is labelled by it.
AlertDialogDescriptionComponentProps<"div">noneThe consequences. The dialog is described by it.
AlertDialogActionsComponentProps<"form">noneA method="dialog" form for the answer buttons. Its first button gets autofocus.

Anatomy

root.eq-alert-dialogThe Dialog, with role="alertdialog" and data-tone.
title.eq-alert-dialog-title
icon.eq-alert-dialog-iconOnly with tone="danger".
description.eq-alert-dialog-description
actions.eq-alert-dialog-actionsA form with method="dialog".

Keyboard

EnterSpaceOn the trigger, opens the dialog and focuses its first action.
TabShift+TabMoves between the actions; the page behind is inert.
EscapeCloses the dialog without an answer and returns focus to the trigger.

Notes

  • Uses role="alertdialog", so screen readers announce it as urgent and read the description along with the title.
  • A click on the backdrop does nothing; only an action or Escape closes it.
  • The first button in AlertDialogActions gets autofocus, so put the least destructive action first.
  • Each action is a submit button with a value; read it from returnValue in onClose.
  • Escape leaves returnValue as it was, so clear it after reading it.
  • Name the action on the confirming button, such as Delete workspace, rather than OK or Yes.
  • Inherits Dialog's fallbacks: invoker commands are emulated with one document click listener on engines without them, and where closedby is missing a backdrop click is simply ignored.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/alert-dialog.tsx95 lines
"use client";

import { createContext, useContext, useId, type ComponentProps } from "react";
import { Dialog, DialogBody, DialogFooter, DialogTitle, type DialogProps } from "./dialog";
import "./alert-dialog.css";

type Tone = "default" | "danger";

const AlertContext = createContext<{ tone: Tone; descriptionId?: string }>({ tone: "default" });

export type AlertDialogProps = Omit<DialogProps, "closedby"> & {
  /** danger marks the title with a warning icon for actions that destroy data. */
  tone?: Tone;
};

/**
 * A Dialog with role="alertdialog" that only Escape or one of its actions can
 * close. A click on the backdrop does nothing.
 */
export function AlertDialog({ tone = "default", className, children, ...rest }: AlertDialogProps) {
  const descriptionId = useId();
  return (
    <AlertContext.Provider value={{ tone, descriptionId }}>
      <Dialog
        role="alertdialog"
        aria-describedby={descriptionId}
        {...rest}
        closedby="closerequest"
        data-tone={tone}
        className={["eq-alert-dialog", className].filter(Boolean).join(" ")}
      >
        {children}
      </Dialog>
    </AlertContext.Provider>
  );
}

export function AlertDialogTitle({ className, children, ...rest }: ComponentProps<"h2">) {
  const { tone } = useContext(AlertContext);
  return (
    <DialogTitle {...rest} className={["eq-alert-dialog-title", className].filter(Boolean).join(" ")}>
      {tone === "danger" && (
        <svg
          className="eq-alert-dialog-icon"
          viewBox="0 0 20 20"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
        >
          <path d="M8.6 3.4 2.3 14.3a1.6 1.6 0 0 0 1.4 2.4h12.6a1.6 1.6 0 0 0 1.4-2.4L11.4 3.4a1.6 1.6 0 0 0-2.8 0Z" />
          <path d="M10 8v3.5M10 14h.01" />
        </svg>
      )}
      {children}
    </DialogTitle>
  );
}

/** The consequence text. The dialog is described by it. */
export function AlertDialogDescription({ className, ...rest }: ComponentProps<"div">) {
  const { descriptionId } = useContext(AlertContext);
  return (
    <DialogBody
      id={descriptionId}
      {...rest}
      className={["eq-alert-dialog-description", className].filter(Boolean).join(" ")}
    />
  );
}

/**
 * A method="dialog" form. Each submit button inside closes the dialog and sets
 * returnValue to its value. The first button gets autofocus, so the least
 * destructive action goes first.
 */
export function AlertDialogActions({ className, ref, ...rest }: ComponentProps<"form">) {
  function attach(node: HTMLFormElement | null) {
    if (node && !node.querySelector("[autofocus]")) {
      node.querySelector("button")?.setAttribute("autofocus", "");
    }
    if (typeof ref === "function") ref(node);
    else if (ref) ref.current = node;
  }

  return (
    <DialogFooter
      {...rest}
      ref={attach}
      className={["eq-alert-dialog-actions", className].filter(Boolean).join(" ")}
    />
  );
}
components/equip/alert-dialog.css19 lines
@layer equip {
  .eq-alert-dialog-title {
    display: flex;
    align-items: flex-start;
    gap: var(--eq-space-2);
  }

  .eq-alert-dialog-icon {
    flex: none;
    width: 20px;
    height: 20px;
    margin-top: 1px;
    color: var(--eq-danger);
  }

  .eq-alert-dialog[data-tone="danger"] .eq-alert-dialog-description {
    padding-left: calc(20px + var(--eq-space-2));
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index