← All parts

03 / Focus & decide

Dialog

A controlled native modal with slots for custom content, forms, and actions.

Try it.

Isolated preview using the source below. In the playground, click Run to start.

Copy. Register. Use.

Keep the template outside your mount region and register the definition on your existing app before mounting. Pass props for data and slots for custom markup. Adapt the example parent state to your app, or create one with brio.createApp({ state: exampleState }). Register once; use multiple instances.

Template + scoped CSS

<template id="brio-dialog-template">
<section class="part-root" b-id="['dialogTitle']"
b-bind-style="{ '--part-accent': $props.accent || null }">
<style>
@scope {
:scope { display: block; font: 15px/1.65 system-ui, sans-serif; color: light-dark(#152139, #edf3ff); }
* { box-sizing: border-box; }
h2 { margin: 0 0 12px; font-size: 1.5rem; line-height: 1.3; letter-spacing: -.035em; }
p { margin: 0 0 16px; }
button, input { font: inherit; color: inherit; }
button { cursor: pointer; background: transparent; border: 1px solid light-dark(#d5e0ef, #344863); border-radius: 7px; padding: 8px 14px; }
:focus-visible { outline: 3px solid var(--part-accent, light-dark(#245fe2, #9bc1ff)); outline-offset: 3px; }
dialog { width: min(460px, calc(100% - 32px)); max-height: calc(100% - 32px); overflow: auto; padding: 24px; background: light-dark(#fff, #111e32); color: inherit; border: 1px solid light-dark(#d5e0ef, #344863); border-radius: 12px; }
dialog::backdrop { background: #071427a6; }
label { display: grid; gap: 6px; }
input { width: 100%; padding: 8px 10px; background: transparent; border: 1px solid light-dark(#d5e0ef, #344863); border-radius: 6px; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.primary { background: var(--part-accent, light-dark(#245fe2, #9bc1ff)); color: light-dark(#fff, #111e32); }
}
</style>
<dialog b-ref="dialog" b-bind-aria-labelledby="$id.dialogTitle"
b-on-cancel="$data.onCancel($event)" b-on-close="$data.onClose()">
<h2 b-bind-id="$id.dialogTitle"><slot name="title"><span b-text="$props.title"></span></slot></h2>
<slot></slot>
<slot name="footer">
<div class="actions">
<button type="button" b-on-click="$data.requestClose()" b-text="$props['close-label']"></button>
</div>
</slot>
</dialog>
</section>
</template>

Component definition

const dialogComponent = {
template: '#brio-dialog-template',
props: { open: Boolean, title: String, accent: String, 'close-label': String },
setup({ el, $props, $emit, $ref }) {
brio.effect(() => {
const dialog = $ref.dialog;
if (!dialog) return;
if ($props.open && !dialog.open) dialog.showModal();
else if (!$props.open && dialog.open) dialog.close();
});
brio.onCleanup(() => {
const dialog = el.querySelector('dialog');
if (dialog?.open) dialog.close();
});
return {
requestClose() {
$emit('request-close');
},
onCancel(event) {
event.preventDefault();
this.requestClose();
},
onClose() {
$emit('closed');
},
};
},
};

Register on your app

app.component('brio-dialog', dialogComponent);

Example parent data

const exampleState = () => ({
content: {
"title": "Make room for a pause.",
"description": "A modal built on the browser's own dialog element.",
"triggerLabel": "Edit display name",
"dialogTitle": "What should we call you?",
"body": "This demo changes the preview only. Nothing is sent to a server.",
"inputLabel": "Display name",
"maxLength": 60,
"value": "",
"cancelLabel": "Cancel",
"saveLabel": "Save name",
"message": "No changes yet."
},
open: false,
draft: '',
edit() {
this.draft = this.content.value;
this.open = true;
},
save() {
this.content.value = this.draft.trim();
this.content.message = this.content.value ? `Hello, ${this.content.value}.` : 'Display name cleared.';
this.open = false;
}
});

Use in your app

<h2 b-text="$data.content.title"></h2>
<p b-text="$data.content.description"></p>
<button type="button" b-on-click="$data.edit()" b-text="$data.content.triggerLabel"></button>
<p role="status" b-text="$data.content.message"></p>
<brio-dialog b-prop-open="$data.open" b-prop-title="$data.content.dialogTitle"
b-prop-close-label="$data.content.cancelLabel" b-on-request-close="$data.open = false">
<form b-on-submit.prevent="$data.save()">
<p b-text="$data.content.body"></p>
<label><span b-text="$data.content.inputLabel"></span>
<input type="text" b-bind-maxlength="$data.content.maxLength" b-model="$data.draft">
</label>
<div class="actions">
<button type="button" b-on-click="$data.open = false" b-text="$data.content.cancelLabel"></button>
<button type="submit" class="primary" b-text="$data.content.saveLabel"></button>
</div>
</form>
<template slot="footer" b-template></template>
</brio-dialog>

Example parent styles

@scope (#parts-demo) to (brio-dialog) {
:scope { font: 15px/1.65 system-ui, sans-serif; color: light-dark(#152139, #edf3ff); }
h2 { margin: 0 0 12px; font-size: 1.5rem; line-height: 1.3; letter-spacing: -.035em; }
p { margin: 0 0 16px; }
button { font: inherit; cursor: pointer; padding: 8px 14px; border: 1px solid light-dark(#d5e0ef, #344863); border-radius: 7px; background: light-dark(#245fe2, #9bc1ff); color: light-dark(#fff, #111e32); }
button:focus-visible { outline: 3px solid light-dark(#245fe2, #9bc1ff); outline-offset: 3px; }
[role="status"] { margin-top: 16px; }
}

Make it yours.

Bind open and title props. Use the default slot for custom content, title for heading markup, and footer for actions. Set close-label when using the default close button. Slotted forms and handlers use parent state; the example only updates an in-memory display name.

Content, props & slots

Default: dialog body. title: heading contents. footer: custom actions (replaces the default close button). Keep a meaningful title and an explicit close control.

Slotted markup uses the parent's state, refs, and handlers. Keep slot templates trusted. Default content is used when no matching slot is supplied.

Bind b-prop-open to parent state. Handle b-on-request-close by setting that state to false; the component emits closed after the native dialog closes. Keep an opener outside the component. Put your own form in the default slot and handle its b-on-submit.prevent; Enter then submits normally. The component never saves or edits your data.

b-prop-* supplies component inputs; b-bind-* updates the host DOM. To spread inputs, use b-props: its keys override named props regardless of attribute order.

Styling & requirements

Requires Brio 5 core, JavaScript, and native CSS @scope and light-dark() support. CSS is included in the template; no add-ons or global stylesheet is required. No CSS polyfill is supplied. Colors follow the document's color-scheme; set it to light dark to follow system preference.

The example heading, opener, and status belong to the parent page. Example parent styles are provided separately; the dialog's scoped CSS does not style outside content.

Set the accent prop with b-prop-accent="$data.accent", or bind --part-accent on the host or a wrapper. The accent prop takes precedence over an inherited custom property. Native scoped CSS does not block global selectors or inherited styles; check your app's resets and theme. Each instance has local state and unique generated IDs.

For npm, import onCleanup from @jnreis/briojs and replace brio.onCleanup in the definition. Also import effect and replace brio.effect.

Integration & accessibility

Components render on mount; provide fallback markup if content must be available without JavaScript. Keep templates trusted. Use Brio structural directives for component removal and app.dispose() before removing the app. With the standard bundle, expressions require CSP unsafe-eval. The CSP build rejects #id templates, so pass the template markup as a string in the definition before using a Part there. See the integration guide for lifecycle and server boundaries.

Test your final labels, contrast, zoom, touch, and screen-reader behavior. These recipes are not an accessibility certification.