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
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.
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.
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
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Required. Trigger buttons point at it with commandfor. |
tone | "default" | "danger" | "default" | danger puts a warning icon before the title, for actions that destroy data. |
...rest | ComponentProps<"dialog"> | none | Every native dialog attribute except closedby, which is fixed to closerequest. Includes onClose and ref. |
AlertDialogTitle | ComponentProps<"h2"> | none | The question. The dialog is labelled by it. |
AlertDialogDescription | ComponentProps<"div"> | none | The consequences. The dialog is described by it. |
AlertDialogActions | ComponentProps<"form"> | none | A method="dialog" form for the answer buttons. Its first button gets autofocus. |
Anatomy
| root | .eq-alert-dialog | The Dialog, with role="alertdialog" and data-tone. |
|---|---|---|
| title | .eq-alert-dialog-title | |
| icon | .eq-alert-dialog-icon | Only with tone="danger". |
| description | .eq-alert-dialog-description | |
| actions | .eq-alert-dialog-actions | A form with method="dialog". |
Keyboard
| EnterSpace | On the trigger, opens the dialog and focuses its first action. |
|---|---|
| TabShift+Tab | Moves between the actions; the page behind is inert. |
| Escape | Closes 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));
}
}