Skip to content

Dialogs and toasts

This is a guide, not the contract. What the platform guarantees is specified under openspec/specs/. For this page: ui-primitives · host-services. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.

Six calls open a dialog: confirm, alert, prompt, open, progress and withProgress. The first three are the lanes ctx.ui exposes to a plugin; open takes your own component as the body; the last two show a busy indicator. message is Markdown; tone colours the icon and the confirming button.

Do it

const dialogs = inject(DialogService);
if (await dialogs.confirm({ title: 'account.close', message: 'This **cannot** be undone.', tone: 'danger' })) {
// …
}
await dialogs.alert({ message: 'Signed out.', tone: 'success' });
const name = await dialogs.prompt({ message: 'Workspace name?' }); // string | null
const ref = dialogs.open(MyLoginDialog, { size: 'md', title: 'auth.signIn' });
const result = await ref.closed;
await dialogs.withProgress({ message: 'Migrating…' }, migrateEverything());
const busy = dialogs.progress({ message: 'Indexing…' }); busy.update('Almost done'); busy.close();
const toasts = inject(NotificationService);
const id = toasts.show({ message: 'settings.saved', kind: 'success', timeoutMs: 4000 });
toasts.dismiss(id);

Read it

The open dialogs are dialogs.dialogs(), oldest first; the last one is topmost. Each is a DialogInstance whose kind is a DialogKind, one of confirm, alert, prompt, custom and progress, and whose buttons are DialogButtonViews with a ButtonRole of confirm, cancel or custom, which is what a custom outlet reads to draw them. What is on screen right now is toasts.notifications(). Opening your own component returns a DialogRef: closed is a promise of the result, close(result) settles it, and maximized with toggleMaximized() serve dialogs opened with maximizable: true.

What asks about unsaved work

Nothing on this page asks: a dialog or a toast closes no surface. The unsaved-work question is itself a dialog the shell opens through this service.

Switched off

No switch governs dialogs or toasts.

In depth

Shared options. confirm, alert and prompt share title?, message (Markdown), tone? and icon?, plus their own labels. confirm additionally takes requireConfirmation, a typed guard for a destructive action. Its validate returns null to allow and a string to block: a non-empty string is shown as the reason, an empty one blocks silently.

Progress. progress() returns a handle you close yourself; withProgress() ties the dialog to a promise and is what you want almost always.

Your own component as the body. OpenOptions:

OptionEffect
title, icon, tonethe host-drawn frame around your component
datapassed to your component through the DialogRef
buttonshost-drawn footer buttons; each { label, variant?, value? } resolves closed with its value
sizemd (default), lg, xl
dismisswhich of the user’s ways close it: any (default), explicit or none; see below
maximizablethe frame offers a maximize/restore control
barerender only your component — no frame, no padding, no footer; you own the chrome
aligncenter (default) or top, which pins the panel near the top on every width

How the user may close it. dismiss governs the user only; your component and your own code can always call DialogRef.close, and declared buttons work whatever you choose.

ValueBackdrop clickEscape, close controlClose control drawn
anyclosescloseyes
explicitdoes nothingcloseyes
nonedoes nothingdo nothingno

An Escape pressed in an open <lw-select> or <lw-menu> inside the dialog closes that list only; the next Escape closes the dialog. A popup your component draws itself gets the same by calling preventDefault() on the Escape it handles. When the dialog closes, by any way, the focus goes back to the control that opened it.

dismissable is gone. Replace dismissable: true by nothing and dismissable: false by dismiss: 'none'.

Keeping unsaved edits. Three kinds of dialog edit something, and each needs something different:

  • A form with its own Save and Cancel. Open it with dismiss: 'explicit'. A stray click beside it does nothing, while Escape and the close control read as cancel, as your Cancel button does.
  • A dialog that asks on closing, with no Save button of its own. Implement DirtySurface on the component, the same interface a tab’s content implements. While surfaceDirty() returns true, every way of closing the user is allowed asks Save · Discard · Cancel, with Save offered only when you implement surfaceSave. A surfaceBeforeClose veto runs first, with the same timeout a tab gets. A declared button without a value counts as a cancel and asks too; one with a value, and DialogRef.close, never ask.
  • A dialog whose changes apply as they are made, such as the settings. Nothing is ever unsaved, so implement nothing and keep the default. If a write may still be in flight when the user closes it, report dirty until the write has succeeded, and the dialog asks rather than losing it.
const name = await this.dialogs.open<string>(EditNameForm, {
title: 'Edit name',
dismiss: 'explicit',
}).closed;

When the frame does not fit. bare and align: 'top' exist for the two cases the standard frame does not fit. One is a surface that draws its own two-column chrome, such as the settings dialog. The other is a panel whose height follows a filtering list, such as the command palette; centred, it would jump around as results change.

Toasts. kind is info | success | warning | error. Omitting timeoutMs makes the toast sticky: it stays until the user dismisses it, which is right for “an update is waiting” and wrong for almost everything else. A single action adds a button. Passing the same id twice replaces the toast instead of stacking a second one.

Where the story is told