Skip to content
Andrew SmithSolutions
← Component library

Dialog

Overlay

A modal built on <dialog>.showModal(), which hands you the focus trap, the backdrop, Escape and the inert page for free.

<dialog>Focus trap@starting-styleCSS only — no dependency

Preview

Remove this case study?

It comes off the work index and its page starts returning 404. Anyone holding the link will land on nothing.

Escape closes it, Tab stays inside it, and focus returns to the button — none of which is wired up here. It is what showModal() does.

Controls

Notes

showModal() gives you the top layer, a ::backdrop no other element can produce, a real focus trap across Tab and Shift+Tab, Escape to close, the rest of the page made inert, and focus returned to whatever opened it. A div with a fixed overlay has to re-earn all six, and usually stops after the first two.

showModal(), never show(). show() opens the dialog non-modally: no backdrop, no focus trap, no inert page — every part you wanted, absent, with no error to tell you.

The cancel button is a submit inside <form method="dialog">, which closes the dialog and restores focus with no JavaScript at all.

Backdrop clicks work because ::backdrop is not an event target, so a click on it lands on the <dialog> element itself. That only distinguishes it from a click on the panel because the dialog carries no padding and the panel is a child — put padding on the dialog and its own edge starts closing it.

autofocus goes on the confirm button. Without it focus lands on the first tabbable element, which is Cancel, and the reader starts on the action they did not come for.

@starting-style and transition-behavior: allow-discrete are what make it animate. The first supplies a "from" for an element that did not exist a frame earlier; the second keeps display and overlay in place until the exit finishes. Drop allow-discrete from overlay and the dialog leaves the top layer at once, so the fade-out plays behind the page.

The scrim is a plain black at 55%, not a token. It has to darken in both themes — an --ink scrim would be white on the dark palette, which is a flash rather than a scrim.

destructive accents the confirm button and changes nothing else. The wording is what tells someone what they are about to do; the colour only helps people who already knew.

Source

Show
"use client";

import { useId, useRef } from "react";
import { cn } from "@/lib/cn";

/**
 * A modal built on <dialog>.showModal(), which is the whole point.
 *
 * What the browser hands you for free, and what a div-with-a-fixed-overlay has
 * to re-earn — usually badly:
 *
 *   the top layer, so no z-index race and no stacking-context surprises
 *   ::backdrop, which no other element can produce
 *   a real focus trap for Tab and Shift+Tab
 *   Escape to close
 *   the rest of the page made inert
 *   focus returned to whatever opened it, on close
 *
 * The entry and exit animate because of two newer pieces: @starting-style
 * gives the transition a "from" for an element that did not exist a frame ago,
 * and transition-behavior: allow-discrete lets `display` and `overlay` stay put
 * until the exit finishes. Without allow-discrete on `overlay` the dialog
 * leaves the top layer immediately and the fade-out plays behind the page.
 */
const css = `
.dlg {
  padding: 0;
  border: 0;
  background: transparent;
  max-inline-size: min(92vw, var(--dlg-w, 30rem));
  opacity: 0;
  scale: 0.97;
  transition:
    opacity 0.2s ease,
    scale 0.2s ease,
    overlay 0.2s allow-discrete,
    display 0.2s allow-discrete;
}
.dlg[open] { opacity: 1; scale: 1; }
@starting-style {
  .dlg[open] { opacity: 0; scale: 0.97; }
}

/* The scrim darkens in BOTH themes, so it is not a palette colour and does not
   come from a token. An --ink scrim would be white-on-dark, which is a flash,
   not a scrim. */
.dlg::backdrop {
  background: rgb(0 0 0 / 0);
  backdrop-filter: blur(2px);
  transition:
    background 0.2s ease,
    overlay 0.2s allow-discrete,
    display 0.2s allow-discrete;
}
.dlg[open]::backdrop { background: rgb(0 0 0 / 0.55); }
@starting-style {
  .dlg[open]::backdrop { background: rgb(0 0 0 / 0); }
}

@media (prefers-reduced-motion: reduce) {
  .dlg,
  .dlg::backdrop { transition: none; }
}
`;

const widths = { sm: "22rem", md: "30rem", lg: "38rem" } as const;

export type DialogSize = keyof typeof widths;

export function Dialog({
  triggerLabel,
  title,
  confirmLabel = "Confirm",
  cancelLabel = "Cancel",
  size = "md",
  destructive = false,
  dismissible = true,
  onConfirm,
  children,
}: {
  triggerLabel: string;
  title: string;
  confirmLabel?: string;
  cancelLabel?: string;
  size?: DialogSize;
  /** Accents the confirm button. Does not change what the button does. */
  destructive?: boolean;
  /** Close on a backdrop click. Escape always works regardless. */
  dismissible?: boolean;
  onConfirm?: () => void;
  children: React.ReactNode;
}) {
  const id = useId();
  const titleId = `${id}-title`;
  const dialog = useRef<HTMLDialogElement>(null);

  return (
    <>
      <style href="dialog-modal" precedence="default">
        {css}
      </style>

      <button
        type="button"
        // showModal(), never show(). show() opens a non-modal dialog: no
        // backdrop, no focus trap, no inert page — all the parts you wanted.
        onClick={() => dialog.current?.showModal()}
        className="rounded-full bg-ink px-5 py-2.5 text-sm font-medium text-paper transition-opacity hover:opacity-85"
      >
        {triggerLabel}
      </button>

      <dialog
        ref={dialog}
        aria-labelledby={titleId}
        className="dlg"
        style={{ "--dlg-w": widths[size] } as React.CSSProperties}
        onClick={
          dismissible
            ? (event) => {
                // ::backdrop is not an event target, so a click on it lands on
                // the <dialog> itself. That only tells them apart because the
                // dialog carries no padding and the panel below is a child —
                // put padding on the dialog and its own edge closes it.
                if (event.target === dialog.current) dialog.current?.close();
              }
            : undefined
        }
      >
        <div className="rounded-2xl border border-rule bg-paper-raised p-6 text-left">
          <h2 id={titleId} className="text-xl font-medium text-ink">
            {title}
          </h2>

          <div className="mt-3 text-[15px] leading-relaxed text-ink-muted">{children}</div>

          <div className="mt-7 flex flex-wrap justify-end gap-2">
            {/* method="dialog" closes the dialog and returns focus with no
                JavaScript at all. The cancel path needs no handler. */}
            <form method="dialog">
              <button
                type="submit"
                className="rounded-full border border-control-border px-5 py-2 text-[13px] text-ink transition-colors hover:border-ink hover:bg-paper-sunken"
              >
                {cancelLabel}
              </button>
            </form>

            <button
              type="button"
              // autofocus lands here rather than on the first tabbable thing,
              // which would otherwise be Cancel.
              autoFocus
              onClick={() => {
                onConfirm?.();
                dialog.current?.close();
              }}
              className={cn(
                "rounded-full px-5 py-2 text-[13px] font-medium transition-colors",
                destructive
                  ? "bg-accent text-on-accent hover:bg-accent-hover"
                  : "bg-ink text-paper hover:opacity-85",
              )}
            >
              {confirmLabel}
            </button>
          </div>
        </div>
      </dialog>
    </>
  );
}

export default function Demo({
  title = "Remove this case study?",
  size = "md",
  destructive = true,
  dismissible = true,
}: {
  title?: string;
  size?: DialogSize;
  destructive?: boolean;
  dismissible?: boolean;
}) {
  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-5">
      <Dialog
        triggerLabel="Open dialog"
        title={title}
        confirmLabel={destructive ? "Remove it" : "Save changes"}
        size={size}
        destructive={destructive}
        dismissible={dismissible}
      >
        It comes off the work index and its page starts returning 404. Anyone
        holding the link will land on nothing.
      </Dialog>

      <p className="text-center text-[12.5px] leading-relaxed text-ink-faint">
        Escape closes it, Tab stays inside it, and focus returns to the button —
        none of which is wired up here. It is what showModal() does.
      </p>
    </div>
  );
}

Next

Toast

Next step

Tell me where the business is losing time.

No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.