Dialog
Modal overlay
Intent
Dialog is a modal overlay for interactions that must
interrupt the user's flow -- a confirmation before a
destructive action, a form for a focused task, a detail
view of a selected item. It builds on the native
<dialog> element, which brings focus
trapping, Escape handling, and inert backdrop semantics for
free. brio adds chrome (header / body / footer slots, size
and color variants) and a small custom element
(<brio-dialog>) that layers
backdrop-click dismissal and non-dismissible Escape control
on top of the native behavior.
Reach for drawer when the overlay should slide in from an edge rather than sit at the page's center -- long-form settings, side panels, mobile navigation. Reach for details when the content can live inline. Dialog is for modal interactions: the user must engage with the overlay before the rest of the page comes back.
Basic usage
A dialog needs three things: the
<brio-dialog> wrapper (for backdrop /
Escape control), the native <dialog
class="dialog"> with a stable id, and
an invoker button that references it via
commandfor="id" and
command="show-modal". No JavaScript beyond the
native element and brio's small wrapper; no open/close
state to manage in your application.
<brio-dialog dismissible="">
<dialog class="dialog" id="demo-basic">
<header>
<h3>Publish draft</h3>
<button class="dismiss" type="button" commandfor="demo-basic" command="close" aria-label="Close">×</button>
</header>
<section>
<p>Your draft is ready to go live. Publishing makes it visible to all subscribers; you can always edit afterward.</p>
</section>
<footer>
<button class="button is-outlined" type="button" commandfor="demo-basic" command="close">Cancel</button>
<button class="button" type="button" commandfor="demo-basic" command="close">Publish</button>
</footer>
</dialog>
</brio-dialog>
<button class="button" type="button" commandfor="demo-basic" command="show-modal">Open dialog</button>
Click the button to open the dialog. Escape closes it; the
dismissible attribute on <brio-dialog>
also lets a click outside the dialog content close it. The
Cancel and Publish buttons both close the dialog via
command="close"; in a real application the
Publish button would additionally fire the actual publish
action (a form submit, an API call) before or after
closing.
Slots
Three structural slots inside the dialog, keyed by element.
Each has its own chrome (borders, padding), making the
typical title-plus-content-plus-actions shape readable at a
glance. Slots are direct children of the
<dialog> -- a nested
<header> or <footer>
inside body content (e.g. an .alert with its
own footer) does not pick up dialog chrome.
Form-method-dialog wrapper. When the
consumer wants <button value> →
dialog.returnValue plumbing (see
Reading the user's choice), a
<form method="dialog"> can wrap the three
slots. The CSS handles both the bare and form-wrapped
shapes; the form acts as a transparent shell.
Header (<header>)
A
<header>
as direct child of the dialog. Flex row at the top --
typically holds a heading and a dismiss button; the CSS
gives it justify-content: space-between so the
title sits left and the dismiss sits right. A bottom border
separates it from the body. Pick a heading level
(h2–h6) that fits the document's
hierarchy -- dialog normalizes the visual size via
--dialog-title-size.
Body (<section> or <article>)
A
<section>
is the canonical fit -- "thematic grouping" is what a
dialog body usually is. An
<article>
is also accepted for bodies that hold a self-contained
composition (a single notification, a single news item
displayed in a dialog). Padded content block; takes
whatever you put inside -- paragraphs, form fields, a list,
a nested component. In the .is-full size
variant the body becomes scrollable
(overflow-y: auto) so long content doesn't
push the dialog past the viewport.
Footer (<footer>)
A
<footer>
as direct child of the dialog. Right-aligned action row,
separated from the body by a top border. Use for the
primary / secondary button pair (cancel plus confirm,
discard plus save). Flex with
justify-content: flex-end handles the
alignment; flex-wrap lets buttons wrap
gracefully at narrow widths without overflowing.
Variants
Size
Size variants cover most use cases.
.is-sm for tight confirmations and simple alerts;
the default for typical forms and detail views;
.is-lg for longer forms or multi-column layouts;
.is-full for dialogs that should fill the viewport
with internal scroll (useful on mobile or for content-heavy
overlays).
<div>
<brio-dialog dismissible="">
<dialog class="dialog is-sm" id="demo-sm">
<header>
<h3>Delete this item?</h3>
</header>
<section>
<p>This can't be undone.</p>
</section>
<footer>
<button class="button is-outlined" type="button" commandfor="demo-sm" command="close">Cancel</button>
<button class="button is-danger" type="button" commandfor="demo-sm" command="close">Delete</button>
</footer>
</dialog>
</brio-dialog>
<brio-dialog dismissible="">
<dialog class="dialog is-lg" id="demo-lg">
<header>
<h3>Edit project</h3>
<button class="dismiss" type="button" commandfor="demo-lg" command="close" aria-label="Close">×</button>
</header>
<section>
<p>Wider surface for forms with multiple fields or side-by-side layouts. At this size the body still grows to its content; reach for <code>.is-full</code> when the content is tall enough to need internal scroll.</p>
</section>
<footer>
<button class="button is-outlined" type="button" commandfor="demo-lg" command="close">Cancel</button>
<button class="button" type="button" commandfor="demo-lg" command="close">Save</button>
</footer>
</dialog>
</brio-dialog>
<brio-dialog dismissible="">
<dialog class="dialog is-full" id="demo-full">
<header>
<h3>Terms of service</h3>
<button class="dismiss" type="button" commandfor="demo-full" command="close" aria-label="Close">×</button>
</header>
<section>
<p>Fills the viewport with padding and gives the body its own internal scroll. Useful for long-form content (terms, release notes, changelogs) or content-heavy dialogs on mobile.</p>
<p>Scroll me: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
</section>
<footer>
<button class="button" type="button" commandfor="demo-full" command="close">Close</button>
</footer>
</dialog>
</brio-dialog>
<div class="cluster is-tight">
<button class="button is-outlined" type="button" commandfor="demo-sm" command="show-modal">Open .is-sm</button>
<button class="button is-outlined" type="button" commandfor="demo-lg" command="show-modal">Open .is-lg</button>
<button class="button is-outlined" type="button" commandfor="demo-full" command="show-modal">Open .is-full</button>
</div>
</div>Color
.is-danger tints the dialog with the danger
color family -- red-subtle background, borders, danger
text -- signalling a destructive action.
<div>
<brio-dialog dismissible="">
<dialog class="dialog is-danger" id="demo-danger">
<header>
<h3>Delete workspace?</h3>
</header>
<section>
<p>This will permanently delete the workspace, its content, and all member access. The action can't be undone.</p>
<p>Type the workspace name to confirm before proceeding.</p>
</section>
<footer>
<button class="button is-danger is-outlined" type="button" commandfor="demo-danger" command="close">Cancel</button>
<button class="button is-danger" type="button" commandfor="demo-danger" command="close">Delete</button>
</footer>
</dialog>
</brio-dialog>
<button class="button is-danger" type="button" commandfor="demo-danger" command="show-modal">Open destructive dialog</button>
</div>State and interaction
Dialog's open state lives on the native [open]
attribute. Invokers flip the state via the
command and commandfor attributes
(native invoker buttons, CSSWG-tracked and shipping) -- no
JavaScript in the consumer application:
- Open modal:
<button commandfor="dialog-id" command="show-modal">fires.showModal()on the dialog. - Close:
<button commandfor="dialog-id" command="close">fires.close(). Works from any invoker on the page, inside or outside the dialog. - Native Escape: built in to
<dialog>; closes the dialog unconditionally when native. See below for the non-dismissible override.
Dismissible vs non-dismissible. The
<brio-dialog dismissible> wrapper adds
two behaviors: a click on the backdrop closes the dialog,
and Escape behaves as the native cancel. Omit
dismissible for dialogs that must require an
explicit button press -- confirmation dialogs, destructive
actions, or multi-step flows where a stray Escape would
lose work. Non-dismissible dialogs still respond to
Escape for the native cancel event, which
<brio-dialog> intercepts and blocks.
<div>
<brio-dialog>
<dialog class="dialog is-danger" id="demo-nondismissible">
<header>
<h3>Confirm before continuing</h3>
</header>
<section>
<p>This dialog is non-dismissible: Escape and backdrop clicks don't close it. The reader must press a button to proceed. Use sparingly -- only for actions where an accidental dismiss would cause harm.</p>
</section>
<footer>
<button class="button is-danger is-outlined" type="button" commandfor="demo-nondismissible" command="close">Cancel</button>
<button class="button is-danger" type="button" commandfor="demo-nondismissible" command="close">Proceed</button>
</footer>
</dialog>
</brio-dialog>
<button class="button is-danger is-outlined" type="button" commandfor="demo-nondismissible" command="show-modal">Open non-dismissible</button>
</div>Reading the user's choice
The dialogs above close without telling the caller which
button the user pressed. For confirmation flows that need
to branch on the answer, switch the action buttons from
command="close" invokers to a native
<form method="dialog">: when a button
inside that form is clicked, the browser closes the dialog
automatically, sets dialog.returnValue to the
submitted button's value, and fires a
close event. No JavaScript on the buttons; a
single listener on the dialog reads the result.
<brio-dialog dismissible="">
<dialog class="dialog is-sm is-danger" id="demo-return">
<form method="dialog">
<header>
<h3>Delete this item?</h3>
</header>
<section>
<p>This can't be undone.</p>
</section>
<footer>
<button class="button is-danger is-outlined" value="cancel">Cancel</button>
<button class="button is-danger" value="delete">Delete</button>
</footer>
</form>
</dialog>
</brio-dialog>
<div class="cluster is-tight">
<button class="button is-danger" type="button" commandfor="demo-return" command="show-modal">Open</button>
<p id="demo-return-result" class="text-muted" aria-live="polite"></p>
</div>
<script>
(() => {
const dlg = document.getElementById("demo-return");
const out = document.getElementById("demo-return-result");
dlg.addEventListener("close", () => {
const v = dlg.returnValue;
out.textContent = v === "delete" ? "Result: delete"
: v === "cancel" ? "Result: cancel"
: "Result: dismissed (returnValue empty)";
});
})();
</script>A few rules the platform sets for you:
-
Form-submit closes the dialog. Buttons
inside
<form method="dialog">don't needcommandfororcommand="close"-- the form submission itself closes the dialog and carries the button'svaluethrough toreturnValue. -
Escape resolves to the empty string.
On a dismissible dialog, pressing Escape closes with
returnValue === "". Same for programmaticdialog.close()called without an argument and for backdrop dismissal. Treat empty as "the user did not actively pick an action" rather than parsing why. -
The
closeevent always fires. Whatever path closed the dialog -- form submit, Escape, programmatic call, backdrop click -- the listener runs once. ReadreturnValuefrom inside the handler; don't try to infer the path. -
Mix patterns per button. A dialog can
keep
command="close"on the dismiss X (no value, just a close) while using a<form method="dialog">for the cancel/confirm pair. Pick the right tool per button -- invoker for plain closes, form for closes that carry a result.
Awaiting the result from JavaScript
For code that opens a dialog and wants to act on the
answer in sequence rather than via a listener, brio
exposes brio.dialog.open(idOrElement) -- a
thin Promise wrapper around the native pattern above.
It calls showModal() and resolves with
returnValue when close fires:
<brio-dialog dismissible="">
<dialog class="dialog is-sm" id="demo-await">
<form method="dialog">
<header>
<h3>Save changes?</h3>
</header>
<section>
<p>Your draft has unsaved changes.</p>
</section>
<footer>
<button class="button is-outlined" value="discard">Discard</button>
<button class="button" value="save">Save</button>
</footer>
</form>
</dialog>
</brio-dialog>
<div class="cluster is-tight">
<button class="button" type="button" id="demo-await-trigger">Open</button>
<p id="demo-await-result" class="text-muted" aria-live="polite"></p>
</div>
<script>
(() => {
const trigger = document.getElementById("demo-await-trigger");
const out = document.getElementById("demo-await-result");
trigger.addEventListener("click", async () => {
const result = await brio.dialog.open("demo-await");
out.textContent = result === "save" ? "Result: save"
: result === "discard" ? "Result: discard"
: "Result: dismissed (returnValue empty)";
});
})();
</script>
The same string contract as the native pattern: button
value on form-submit; "" on
Escape, backdrop, programmatic
dialog.close(), or wrapper teardown. The
Promise never rejects under normal use --
showModal() can throw
InvalidStateError synchronously if the dialog
is already open, and that surfaces at the call site as a
programmer error.
A few notes:
-
Argument is id or element. Pass a
string id (looked up via
getElementById) or theHTMLDialogElementdirectly. Anything else throws. -
Don't mix with
command="show-modal"on the same flow. If you're opening from JS withbrio.dialog.open(), the trigger is a plaintype="button"with a click handler. Reach for the invoker attributes when the opener is declarative and you don't need an awaited result. -
Disconnect-safe. If the
<brio-dialog>wrapper is removed from the DOM before the dialog closes, the pending Promise resolves with""rather than hanging. Route changes and conditional rendering stay clean.
Animation
Entry and exit animations are fully CSS-driven. Entry
uses @starting-style to declare the starting
values for opacity and transform; exit uses the
:not([open]) selector as the transition
target. Both transitions include
overlay and display with
allow-discrete so the dialog holds its
visible state during the exit animation rather than
snapping away.
Browsers without @starting-style or
allow-discrete support skip the animation
entirely; the dialog still opens and closes via the
native [open] attribute. Fully usable.
Reference
.dialog-- the styled native<dialog>element. Pair with<brio-dialog>for backdrop / Escape control.<header>(direct child) -- title + dismiss row (P2 test 3 -- position anchor). Slot keyed by element, not class.<section>or<article>(direct child) -- padded content block. Scrollable in.is-full. Slot keyed by element.<footer>(direct child) -- right-aligned actions row. Slot keyed by element..is-sm,.is-lg,.is-full-- size variants..is-danger-- danger-tinted header for destructive confirmations..no-border-- opt out of the dialog, header, and footer borders together.
Not classes: <brio-dialog> is a custom
element; commandfor and command
are native HTML invoker attributes;
dismissible is an attribute on
<brio-dialog>;
<form method="dialog"> is the optional
transparent wrapper for the return-value pattern.
Customization
Dialog exposes scoped tokens for color (background, text),
sizing (max-width, horizontal and vertical padding, radius),
borders (dialog, header, footer -- three separate shorthands
so they can be styled independently or stripped together via
.no-border), and the title size. Size variants
remap only --dialog-max-width;
.is-danger tints the header by setting
background-color on the <header> slot
directly and remapping --dialog-header-border
to the danger border.
One detail worth knowing: header and footer use a tighter
vertical padding (ui-pad-y-snug) independently of
--dialog-padding-y, so changing the body's
vertical padding doesn't bulk up the chrome rows.
Full token list and defaults:
src/css/dialog.css.
Accessibility
-
Focus trap is native.
<dialog>opened via.showModal()(triggered bycommand="show-modal") automatically traps keyboard focus inside the dialog. Tab cycles through the dialog's interactive elements; focus returns to the invoker on close. No custom focus-management code required. -
Heading is the label. Screen readers
announce the dialog's content starting with its
heading. Keep the heading short and descriptive -- it
serves as both the visual title and the assistive
label. If no visible heading fits the design, add
aria-labeloraria-labelledbyon the<dialog>element. -
Dismiss needs a name. The
×glyph on the dismiss button isn't announced meaningfully -- addaria-label="Close"(or a localized equivalent). -
Non-dismissible dialogs are a commitment.
When you omit the
dismissibleattribute, the reader cannot press Escape or click away. Use this only when an accidental dismiss would cause real harm (destructive action, in-flight payment, unsaved multi-step flow). Always offer an explicit Cancel button; never leave Proceed as the only exit. - Don't nest dialogs. Opening a dialog from inside another dialog is legal HTML but confusing UX and inconsistent in focus behavior across browsers. Close the first dialog before opening the second, or use a single dialog with a multi-step body.
CSS Reference: Dialog
Modal overlay using the native dialog element
Source: src/css/dialog.css
Tokens
| Token | Default | Description |
|---|---|---|
--dialog-bg | var(--color-surface-float) | Background color for the dialog surface |
--dialog-color | var(--color-text) | Text color inside the dialog |
--dialog-max-width | 32rem | Maximum width of the dialog |
--dialog-padding-x | var(--ui-pad-x-relaxed) | Horizontal padding for header, body, and footer slots |
--dialog-padding-y | var(--ui-pad-y-relaxed) | Vertical padding for the body slot |
--dialog-radius | var(--radius-md) | Border radius for the dialog |
--dialog-border | var(--border-width-default) solid var(--color-border) | Outer border for the dialog |
--dialog-header-border | var(--border-width-default) solid var(--color-border) | Separator border below the header |
--dialog-footer-border | var(--border-width-default) solid var(--color-border) | Separator border above the footer |
--dialog-title-size | var(--ui-text-base) | Font size for the dialog title heading |
--dismiss-hover-bg | var(--color-fill-on-float) | Hover background for the dismiss button inside the dialog |
Slots
| Slot | Description |
|---|---|
.dismiss | Dismiss button with hover tuned for the float surface |
> header, | A header slot starts with a <header> element |
> :is(section, article), | Body slots start with <section> or <article> elements |
> footer, | Footer slots start with a <footer> element |
Classes
| Class | Description |
|---|---|
.dialog | component root |
.is-sm | Narrow dialog at 24rem max width |
.is-lg | Wide dialog at 48rem max width |
.is-full | Near-viewport-width dialog with scrollable body |
.no-border | Strips all borders from the dialog and its slots |
.is-danger | Danger-tinted dialog for destructive actions |