Overlays
Dialog
A modal native dialog for a decision that has to be made before the page can be used again.
- Built on
<dialog>Invoker commandsclosedby<form method="dialog">@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.3 kB
- Dependencies
- React
Code
import { Button } from "@/components/equip/button";
import { Dialog, DialogBody, DialogFooter, DialogTitle } from "@/components/equip/dialog";
export default function Demo() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="demo-rotate-key">
Rotate API key
</Button>
<Dialog id="demo-rotate-key">
<DialogTitle>Rotate API key</DialogTitle>
<DialogBody>
<p>
The production key ending in 4f2a stops working in 24 hours. Services that use it
need the new key before then.
</p>
</DialogBody>
<DialogFooter>
<Button type="submit" variant="outline" value="cancel">
Cancel
</Button>
<Button type="submit" variant="accent" value="rotate">
Rotate key
</Button>
</DialogFooter>
</Dialog>
</>
);
}Examples
Destructive confirmation
Each footer button submits a method="dialog" form, so the dialog closes and its returnValue is the button's value.
Code
import { Button } from "@/components/equip/button";
import { Dialog, DialogBody, DialogFooter, DialogTitle } from "@/components/equip/dialog";
export default function Example() {
return (
<>
<Button variant="danger" command="show-modal" commandfor="example-delete-project">
Delete project
</Button>
<Dialog id="example-delete-project">
<DialogTitle>Delete atlas-web?</DialogTitle>
<DialogBody>
<p>
This removes 14 environments, their deploy history and every preview URL. It cannot be
undone.
</p>
</DialogBody>
<DialogFooter>
<Button type="submit" variant="outline" value="cancel">
Keep project
</Button>
<Button type="submit" variant="danger" value="delete">
Delete project
</Button>
</DialogFooter>
</Dialog>
</>
);
}Long content
The body scrolls while the title and footer stay in view.
Code
import { Button } from "@/components/equip/button";
import { Dialog, DialogBody, DialogFooter, DialogTitle } from "@/components/equip/dialog";
const clauses = [
"Customer data is processed only to provide the hosting service and as documented in the order form.",
"Sub-processors are listed on the trust page and you are told 30 days before a new one is added.",
"Personal data stays in the region you choose at signup unless you move it yourself.",
"Security incidents that affect your data are reported to your account owner within 48 hours.",
"On termination, data is exported on request and deleted from backups within 35 days.",
"Audit reports are shared under NDA once a year, or after a confirmed incident.",
];
export default function Example() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="example-dpa">
Review agreement
</Button>
<Dialog id="example-dpa">
<DialogTitle>Data processing agreement</DialogTitle>
<DialogBody>
<ol style={{ display: "grid", gap: 12, paddingLeft: 20 }}>
{clauses.map((clause) => (
<li key={clause}>{clause}</li>
))}
</ol>
</DialogBody>
<DialogFooter>
<Button type="submit" variant="outline" value="later">
Later
</Button>
<Button type="submit" value="accept">
Accept agreement
</Button>
</DialogFooter>
</Dialog>
</>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Required. Trigger buttons point at it with commandfor. |
closedby | "any" | "closerequest" | "none" | "any" | Which user actions close it. any adds a click on the backdrop to Escape. |
...rest | ComponentProps<"dialog"> | none | Every native dialog attribute, including onClose, onCancel and ref. |
DialogTitle | ComponentProps<"h2"> | none | The heading. The dialog is labelled by it. |
DialogBody | ComponentProps<"div"> | none | The content. Scrolls when the dialog reaches the viewport height. |
DialogFooter | ComponentProps<"form"> | none | A method="dialog" form. Submit buttons inside close the dialog. |
Anatomy
| root | .eq-dialog | The native dialog, shown in the top layer. |
|---|---|---|
| title | .eq-dialog-title | |
| body | .eq-dialog-body | |
| footer | .eq-dialog-footer | A form with method="dialog". |
Keyboard
| EnterSpace | On the trigger, opens the dialog and moves focus into it. |
|---|---|
| TabShift+Tab | Moves focus between the dialog's controls; the page behind is inert. |
| Escape | Closes the dialog and returns focus to the trigger. |
Notes
- Open it from any button with command="show-modal" and commandfor set to the dialog id. React does not type these attributes yet, so dialog.tsx declares them for every button.
- Close it from a submit button in DialogFooter, from a button with command="close", with Escape, or with a click on the backdrop.
- The browser traps focus, makes the page inert, handles Escape and restores focus to the trigger on close.
- It is labelled by DialogTitle through aria-labelledby; pass aria-label instead if there is no visible title.
- Read which button closed it from returnValue in onClose.
- Invoker commands need Chrome 135, Firefox 144 or Safari 26.2; on older engines Dialog adds one document click listener that runs show-modal and close for buttons whose commandfor matches its id. closedby is not in Safari yet; there a click handler closes the dialog when a click lands outside its box. The exit transition needs transition of overlay, which only Chromium has; elsewhere the dialog and backdrop fade in but close at once.
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/dialog.tsx114 lines
"use client";
import {
createContext,
useContext,
useEffect,
useId,
type ComponentProps,
type MouseEvent,
} from "react";
import "./dialog.css";
// React 19 does not type invoker commands yet. This lets any button take them.
declare module "react" {
interface ButtonHTMLAttributes<T> {
command?: string;
commandfor?: string;
}
}
const TitleId = createContext<string | undefined>(undefined);
export type DialogProps = ComponentProps<"dialog"> & {
/** Required. Triggers point at it with commandfor. */
id: string;
};
/**
* Opens with a native invoker: a button carrying command="show-modal" and
* commandfor set to this id. Engines without invoker commands get the same
* behaviour from a document click listener installed below.
*/
export function Dialog({
id,
closedby = "any",
className,
onClick,
children,
...rest
}: DialogProps) {
const titleId = useId();
useEffect(() => {
if ("command" in HTMLButtonElement.prototype) return;
function invoke(event: globalThis.MouseEvent) {
if (!(event.target instanceof Element)) return;
const invoker = event.target.closest("button[commandfor]");
if (!invoker || invoker.getAttribute("commandfor") !== id) return;
const dialog = document.getElementById(id);
if (!(dialog instanceof HTMLDialogElement)) return;
const command = invoker.getAttribute("command");
if (command === "show-modal" && !dialog.open) dialog.showModal();
if ((command === "close" || command === "request-close") && dialog.open) dialog.close();
}
document.addEventListener("click", invoke);
return () => document.removeEventListener("click", invoke);
}, [id]);
function handleClick(event: MouseEvent<HTMLDialogElement>) {
onClick?.(event);
// closedby="any" does this natively where supported.
if (event.defaultPrevented || closedby !== "any") return;
if ("closedBy" in HTMLDialogElement.prototype) return;
const dialog = event.currentTarget;
if (event.target !== dialog) return;
const box = dialog.getBoundingClientRect();
const inside =
event.clientX >= box.left &&
event.clientX <= box.right &&
event.clientY >= box.top &&
event.clientY <= box.bottom;
if (!inside) dialog.close();
}
return (
<TitleId.Provider value={titleId}>
<dialog
aria-labelledby={rest["aria-label"] ? undefined : titleId}
{...rest}
id={id}
closedby={closedby}
className={["eq-dialog", className].filter(Boolean).join(" ")}
onClick={handleClick}
>
{children}
</dialog>
</TitleId.Provider>
);
}
export function DialogTitle({ className, ...rest }: ComponentProps<"h2">) {
const id = useContext(TitleId);
return (
<h2 id={id} {...rest} className={["eq-dialog-title", className].filter(Boolean).join(" ")} />
);
}
export function DialogBody({ className, ...rest }: ComponentProps<"div">) {
return <div {...rest} className={["eq-dialog-body", className].filter(Boolean).join(" ")} />;
}
/**
* A form with method="dialog": any submit button inside closes the dialog
* and sets dialog.returnValue to its value. No JavaScript involved.
*/
export function DialogFooter({ className, ...rest }: ComponentProps<"form">) {
return (
<form
{...rest}
method="dialog"
className={["eq-dialog-footer", className].filter(Boolean).join(" ")}
/>
);
}components/equip/dialog.css133 lines
@layer equip {
.eq-dialog {
box-sizing: border-box;
width: min(440px, calc(100vw - 2 * var(--eq-space-4)));
max-width: none;
max-height: calc(100dvh - 2 * var(--eq-space-5));
padding: var(--eq-space-4);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
opacity: 0;
scale: 0.96;
translate: 0 4px;
/* Modal and centred, so it grows from its own middle. */
transform-origin: center;
/* Leaving: quick, on the plain ease. */
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease);
}
/* Only when open: a display on the closed dialog would override the UA's display: none. */
.eq-dialog[open] {
display: flex;
flex-direction: column;
gap: var(--eq-space-3);
opacity: 1;
scale: none;
translate: none;
/* Arriving: the spring carries it in and lets it settle. */
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-dialog[open] {
opacity: 0;
scale: 0.96;
translate: 0 4px;
}
}
/* The dialog takes focus as a container; its controls carry the ring. */
.eq-dialog:focus-visible {
outline: none;
}
.eq-dialog::backdrop {
background-color: transparent;
transition: background-color var(--eq-dur) var(--eq-ease);
}
/*
* The exit transition keeps the dialog displayed and in the top layer while it fades.
* Without overlay it would drop out of the top layer and flash in the page flow, so the
* exit only runs where overlay can be transitioned.
*/
@supports (overlay: auto) {
.eq-dialog {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-dialog[open] {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-dialog::backdrop {
transition:
background-color var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
}
.eq-dialog[open]::backdrop {
background-color: var(--eq-backdrop);
}
@starting-style {
.eq-dialog[open]::backdrop {
background-color: transparent;
}
}
.eq-dialog-title {
margin: 0;
font-size: var(--eq-text-l);
font-weight: var(--eq-weight-strong);
line-height: 1.3;
letter-spacing: -0.01em;
}
.eq-dialog-body {
min-height: 0;
overflow: auto;
color: var(--eq-ink-2);
}
.eq-dialog-body > :first-child {
margin-top: 0;
}
.eq-dialog-body > :last-child {
margin-bottom: 0;
}
.eq-dialog-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--eq-space-2);
margin: 0;
padding-top: var(--eq-space-1);
}
}