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.

Publish draft

Your draft is ready to go live. Publishing makes it visible to all subscribers; you can always edit afterward.

<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 (h2h6) 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.

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).

Delete this item?

This can't be undone.

Edit project

Wider surface for forms with multiple fields or side-by-side layouts. At this size the body still grows to its content; reach for .is-full when the content is tall enough to need internal scroll.

Terms of service

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.

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.

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.

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.

<div>
    <brio-dialog dismissible="">
        <dialog class="dialog is-sm" id="demo-sm">
            <header>
                <h3>Delete this item?</h3>
            </header>
            <section>
                <p>This can&#39;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.

Delete workspace?

This will permanently delete the workspace, its content, and all member access. The action can't be undone.

Type the workspace name to confirm before proceeding.

<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&#39;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.

Confirm before continuing

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.

<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&#39;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.

Delete this item?

This can't be undone.

<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&#39;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 need commandfor or command="close" -- the form submission itself closes the dialog and carries the button's value through to returnValue.
  • Escape resolves to the empty string. On a dismissible dialog, pressing Escape closes with returnValue === "". Same for programmatic dialog.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 close event always fires. Whatever path closed the dialog -- form submit, Escape, programmatic call, backdrop click -- the listener runs once. Read returnValue from 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:

Save changes?

Your draft has unsaved changes.

<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 the HTMLDialogElement directly. Anything else throws.
  • Don't mix with command="show-modal" on the same flow. If you're opening from JS with brio.dialog.open(), the trigger is a plain type="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 by command="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-label or aria-labelledby on the <dialog> element.
  • Dismiss needs a name. The × glyph on the dismiss button isn't announced meaningfully -- add aria-label="Close" (or a localized equivalent).
  • Non-dismissible dialogs are a commitment. When you omit the dismissible attribute, 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

TokenDefaultDescription
--dialog-bgvar(--color-surface-float)Background color for the dialog surface
--dialog-colorvar(--color-text)Text color inside the dialog
--dialog-max-width32remMaximum width of the dialog
--dialog-padding-xvar(--ui-pad-x-relaxed)Horizontal padding for header, body, and footer slots
--dialog-padding-yvar(--ui-pad-y-relaxed)Vertical padding for the body slot
--dialog-radiusvar(--radius-md)Border radius for the dialog
--dialog-bordervar(--border-width-default) solid var(--color-border)Outer border for the dialog
--dialog-header-bordervar(--border-width-default) solid var(--color-border)Separator border below the header
--dialog-footer-bordervar(--border-width-default) solid var(--color-border)Separator border above the footer
--dialog-title-sizevar(--ui-text-base)Font size for the dialog title heading
--dismiss-hover-bgvar(--color-fill-on-float)Hover background for the dismiss button inside the dialog

Slots

SlotDescription
.dismissDismiss 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

ClassDescription
.dialogcomponent root
.is-smNarrow dialog at 24rem max width
.is-lgWide dialog at 48rem max width
.is-fullNear-viewport-width dialog with scrollable body
.no-borderStrips all borders from the dialog and its slots
.is-dangerDanger-tinted dialog for destructive actions