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

Rotate API key

The production key ending in 4f2a stops working in 24 hours. Services that use it need the new key before then.

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.

Delete atlas-web?

This removes 14 environments, their deploy history and every preview URL. It cannot be undone.

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.

Data processing agreement

  1. Customer data is processed only to provide the hosting service and as documented in the order form.
  2. Sub-processors are listed on the trust page and you are told 30 days before a new one is added.
  3. Personal data stays in the region you choose at signup unless you move it yourself.
  4. Security incidents that affect your data are reported to your account owner within 48 hours.
  5. On termination, data is exported on request and deleted from backups within 35 days.
  6. Audit reports are shared under NDA once a year, or after a confirmed incident.
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

PropTypeDefaultWhat it does
idstringnoneRequired. 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.
...restComponentProps<"dialog">noneEvery native dialog attribute, including onClose, onCancel and ref.
DialogTitleComponentProps<"h2">noneThe heading. The dialog is labelled by it.
DialogBodyComponentProps<"div">noneThe content. Scrolls when the dialog reaches the viewport height.
DialogFooterComponentProps<"form">noneA method="dialog" form. Submit buttons inside close the dialog.

Anatomy

root.eq-dialogThe native dialog, shown in the top layer.
title.eq-dialog-title
body.eq-dialog-body
footer.eq-dialog-footerA form with method="dialog".

Keyboard

EnterSpaceOn the trigger, opens the dialog and moves focus into it.
TabShift+TabMoves focus between the dialog's controls; the page behind is inert.
EscapeCloses 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);
  }
}

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