Skip to content
Beta · ExperimentalReact 19No peer dependencies

Dialog

Alert, confirm and prompt dialogs you call from any event handler and await — no open state to manage.

Beta components are subject to change and may break your code. Use them at your own risk, and share feedback through the bug tracker.

On this page

Installation

bash
npx gbs-add-block@latest -a Dialog -beta

The block copies the dialog folder into your project, along with the small shared folder that every component imports. You own the code and can change it freely. There are no peer dependencies other than React.

Requirements

  • React 19 and @types/react 19
  • TypeScript target ES2022 or newer, with "jsx": "react-jsx"
  • Browsers from 2024 or newer (the styles use light-dark(), :has() and @starting-style)

Import the stylesheet once, for example in your global CSS:

css
@import "../components/dialog/styles.css";

or in your root layout / entry file:

ts
import "@/components/dialog/styles.css";

The Dialog asks the user a short question and gives you the answer: an alert to acknowledge, a confirm to approve or cancel, or a prompt to type a value. Call it from any event handler and await the result. There is no open state to manage and no callback to wire up. Confirm handlers can be async: the dialog shows progress while your request runs, and shows the error in place if it fails. Built on the native <dialog> element, it traps focus and keeps the page behind it unreachable.

For larger content such as forms, use the Modal.

Set the --gbs-* variables on :root to theme every component at once, the grid included. Each component's own variables fall back to them, and then to the built-in palette, so components look identical out of the box.

Default

Live preview

Quick Start

1. Mount the host once, near the root of the app:

tsx
// App.tsx or app/layout.tsx
import { DialogHost } from "@/components/dialog";
 
<body>
  {children}
  <DialogHost />
</body>;

2. Ask from anywhere:

tsx
"use client";
 
import { dialog } from "@/components/dialog";
 
export function DeleteButton({ ids }: { ids: string[] }) {
  return (
    <button
      onClick={async () => {
        const confirmed = await dialog.confirm({
          title: `Delete ${ids.length} invoices?`,
          description: "This can't be undone.",
          intent: "danger",
          confirmLabel: "Delete",
        });
        if (confirmed) await deleteInvoices(ids);
      }}
    >
      Delete
    </button>
  );
}

Each method also accepts a plain string as the title: await dialog.confirm("Leave this page?").

Import dialog and DialogHost from the same path everywhere. They meet through one shared store, and a bundler that sees two different import paths can create two stores — then dialogs never appear.

Props Table

dialog.alert / confirm / prompt

OptionTypeDefaultDescription
titleReactNoderequiredThe question or message. Also names the dialog for screen readers.
descriptionReactNodeDetails under the title.
intent"default" | "info" | "success" | "warning" | "danger""default"Icon and color. danger makes the confirm button red and focuses Cancel first.
iconReactNodethe intent's iconCustom icon; null hides it.
confirmLabelReactNode"OK" (alert), "Confirm"Main button text. Prefer a verb: "Delete", "Send".
cancelLabelReactNode"Cancel"Confirm and prompt only.
dismissiblebooleantrueEscape and a backdrop click count as Cancel.
size"sm" | "md""sm"Width: 420 or 540 px.
onConfirm() => void | Promise<void>; prompt: (value: string) => …Runs before the dialog closes. See Async Confirm.

Prompt adds:

OptionTypeDefaultDescription
defaultValuestring""Starting text.
placeholderstringPlaceholder text.
inputLabelReactNodeLabel above the field.
inputType"text" | "email" | "password" | "number" | "url" | "tel""text"Field type, which also picks the phone keyboard.
requiredbooleanfalseBlocks confirming an empty value with localeText.required.
validate(value: string) => string | null | undefinedReturn a message to block confirming.
MethodResolves with
dialog.alert(options)void, once closed
dialog.confirm(options)true on Confirm; false on Cancel, Escape or a backdrop click
dialog.prompt(options)The text on Confirm; null when canceled
dialog.dismissAll()Cancels the open dialog and every waiting one

DialogHost

PropTypeDefaultDescription
storeDialogStorethe dialog storeA store from createDialogStore(). See Multiple Hosts.
classNamestringClass for every dialog shown.
classNamesPartial<Record<DialogSlot, string>>Slots: root, body, icon, title, description, input, actions, confirm, cancel.
styleCSSPropertiesInline style for every dialog.
localeTextPartial<DialogLocaleText>EnglishSee Locale Text.

Dialog (declarative)

The same dialog as a regular controlled component, for when you'd rather keep it in JSX. It takes every option above, plus:

PropTypeDefaultDescription
openbooleanrequiredWhether it's shown.
kind"alert" | "confirm" | "prompt""confirm"Which buttons and fields it has.
onClose(action: "confirm" | "cancel", value: string) => voidThe user answered. Set open to false here.
onExited() => voidThe exit animation finished.
id, className, classNames, style, localeTextAs on the host.
tsx
const [open, setOpen] = useState(false);
 
<Dialog
  open={open}
  kind="confirm"
  title="Archive this board?"
  onConfirm={archiveBoard}
  onClose={() => setOpen(false)}
/>;

Async Confirm

Give onConfirm an async function to do the work inside the dialog:

tsx
const deleted = await dialog.confirm({
  title: "Delete project “Atlas”?",
  intent: "danger",
  confirmLabel: "Delete project",
  onConfirm: async () => {
    const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
    if (!response.ok)
      throw new Error("The project couldn't be deleted. Try again.");
  },
});
  • While it runs: the buttons show progress, and further clicks, Escape and backdrop clicks are ignored.
  • If it resolves: the dialog closes and confirm resolves true.
  • If it throws: the error's message is shown in the dialog and it stays open, so the user can retry or cancel.

Prompts and Validation

tsx
const email = await dialog.prompt({
  title: "Invite a teammate",
  inputLabel: "Email",
  inputType: "email",
  required: true,
  validate: (value) =>
    /^\S+@\S+\.\S+$/.test(value) ? null : "Enter a valid email",
  onConfirm: (value) => sendInvite(value),
});

Enter confirms. A validation message appears under the field and is announced; typing clears it. validate runs before onConfirm.

Queueing

Dialogs show one at a time. A request made while another dialog is open waits its turn, and each promise resolves with its own answer:

tsx
const [first, second] = await Promise.all([
  dialog.confirm("First?"),
  dialog.confirm("Second?"),
]);

dialog.dismissAll() cancels the open dialog and every waiting one, for example when a session expires.

Multiple Hosts

The ready-made dialog shares one store with every <DialogHost /> that has no store prop. For a separate set of dialogs, for example inside an embedded widget, create your own:

tsx
import {
  createDialogApi,
  createDialogStore,
  DialogHost,
} from "@/components/dialog";
 
const widgetStore = createDialogStore();
export const widgetDialog = createDialogApi(widgetStore);
 
<DialogHost store={widgetStore} />;

Keyboard

KeysAction
EnterConfirm (on the confirm button, or in the prompt field).
EscapeCancel, unless dismissible is false.
Tab / Shift + TabMove between the field and buttons, inside the dialog only.

Accessibility: the dialog is a native modal <dialog> with role="alertdialog", labelled by its title and described by its description. Focus starts on the prompt field, on the confirm button, or on Cancel for a danger confirm, so a stray Enter doesn't destroy anything. While onConfirm runs, the buttons are aria-disabled rather than disabled, so focus stays put. Errors are announced with role="alert".

Styling and Theming

All rules are in the CSS components layer, so utility classes passed through className / classNames override them.

tsx
<DialogHost classNames={{ title: "text-lg", confirm: "min-w-28" }} />

CSS variables

Override them on .dl-root, on :root, or through style. Each variable falls back to the shared --gbs-* of the same name, then to the DataGrid's --dg-* when that stylesheet is loaded, and finally to the built-in palette.

VariableUsed for
--dl-widthWidth (set by size).
--dl-bg, --dl-fgBackground and text color.
--dl-mutedDescription text.
--dl-borderBorders.
--dl-hoverHover background of the Cancel button.
--dl-input-bgPrompt field background.
--dl-accent, --dl-accent-fgThe confirm button.
--dl-danger, --dl-danger-fgThe danger confirm button, and errors.
--dl-info, --dl-success, --dl-warningIcon colors per intent.
--dl-focusFocus ring.
--dl-backdropThe dimmed page behind.
--dl-shadowShadow.
--dl-radiusCorner radius.

Dark mode

Colors follow the page's color-scheme. To force a scheme, put class="dark" or data-theme="dark" (or "light") on an ancestor such as <html>.

Data attributes

ElementAttributes
Root (dialog.dl-root)data-intent, data-size, data-state (open / closed)
Buttons (.dl-button)data-variant (primary, danger, secondary), aria-busy while onConfirm runs

On screens narrower than 480 px the buttons stack at full width.

Locale Text

tsx
<DialogHost
  localeText={{
    ok: "OK",
    confirm: "Bestätigen",
    cancel: "Abbrechen",
    required: "Pflichtfeld",
  }}
/>
KeyDefault
ok"OK"
confirm"Confirm"
cancel"Cancel"
required"Please fill in this field"

Headless Use

The queue and the promise API are exported from the framework-free @/components/dialog/core:

ExportDescription
dialog / dialogStoreThe ready-made API and the store behind it.
createDialogStore()A new queue: open(kind, options), resolve(id, confirmed, value?), dismissAll(), subscribe, getSnapshot.
createDialogApi(store)The alert / confirm / prompt API for a store.
resultFor(kind, confirmed, value)What each kind resolves with.

To draw dialogs your own way, subscribe to a store with useSyncExternalStore, render queue[0], and call store.resolve with the answer.

Next.js

Mount <DialogHost /> in the root layout; it's a client component, and a Server Component layout can render it. dialog.* is for client code: on the server, where nobody can answer, calls resolve right away as a cancel (false, null), so a stray call can't hang a request.

Migrating from the Previous Dialog Box

PreviousNew
<Dialog /> mounted once<DialogHost /> mounted once
messageService.sendMessage({ key: "dialog_alert", type: "confirm", … })await dialog.confirm({ … })
type: "alert"await dialog.alert({ … })
messageService.getMessage().subscribe(…) with callbackUrlThe returned promise: const confirmed = await dialog.confirm(…)
keyData (entity values passed through)Not needed; the values are still in scope where you await
titletitle
contentdescription
okButton: { text, icon }confirmLabel (any ReactNode, so it can include an icon)
cancelButton: { text, icon }cancelLabel
position: { X, Y }Always centered; restyle with className if needed
widthsize (sm / md), or --dl-width
Dependency on gbs-fwk-coreNone
New: prompt, intent, async onConfirm with errors shown in place, queueing, dismissible

Notes

  • One dialog is shown at a time; the rest queue.
  • The dialog is always centered. For a drawer or a large form, use the Modal.
  • A Dialog opened from inside an open Modal stacks above it, and Escape closes the Dialog first.