<baht-modal>
Controlled dialog: you flip open, it asks to close via baht-close. Children become the
body and stay mounted while closed, so a form inside keeps its state across open/close.
For a one-line confirm, notice or prompt you do not need this element at all — the
dialog & toast service (bank20baht-ui/overlay) builds one for you and
resolves a Promise.
Open / close
This cannot be undone.
The modal never closes itself. baht-close carries the reason (escape, scrim, button); set open=false in the handler, or ignore it to block closing mid-save.
<baht-button id="open-btn">Open modal</baht-button>
<baht-modal id="my-modal" heading="Delete workflow?" description="Steps and run history will be removed.">
<p>This cannot be undone.</p>
<div class="baht-modal-footer">
<baht-button variant="secondary" id="cancel-btn">Cancel</baht-button>
<baht-button variant="danger">Delete</baht-button>
</div>
</baht-modal>
<script>
const modal = document.getElementById('my-modal');
document.getElementById('open-btn').addEventListener('click', () => (modal.open = true));
document.getElementById('cancel-btn').addEventListener('click', () => (modal.open = false));
modal.addEventListener('baht-close', () => (modal.open = false));
</script>This cannot be undone.
Tones and error detail
Section titled “Tones and error detail”The body can carry the same classes the overlay service renders: .baht-dialog--info | --success | --warning | --danger draw a 3px accent on the left, .baht-dialog-detail is a
monospace block for the raw error, .baht-dialog-actions right-aligns the buttons. Pair a
danger tone with a danger confirm button.
Error dialog (danger tone + detail)
ValidationError: fields[3].key “amount” duplicates fields[1].key
<baht-button id="error-open" variant="danger">Show error</baht-button>
<baht-modal id="error-modal" heading="Import failed" show-close="false">
<div class="baht-dialog baht-dialog--danger">
<p class="baht-dialog-message">The file was read, but two fields share a key.</p>
<pre class="baht-dialog-detail">ValidationError: fields[3].key “amount” duplicates fields[1].key</pre>
<div class="baht-dialog-actions">
<baht-button id="error-ok">OK</baht-button>
</div>
</div>
</baht-modal>
<script>
const modal = document.getElementById('error-modal');
document.getElementById('error-open').addEventListener('click', () => (modal.open = true));
document.getElementById('error-ok').addEventListener('click', () => (modal.open = false));
modal.addEventListener('baht-close', () => (modal.open = false));
</script>ValidationError: fields[3].key “amount” duplicates fields[1].key
Warning tone, no dismiss paths
show-close="false" leaves only your buttons (a scrim click never closes unless you add close-on-scrim). Escape still emits baht-close — ignore it to make the choice mandatory.
<baht-button id="warn-open" variant="secondary">Leave page</baht-button>
<baht-modal id="warn-modal" heading="Unsaved changes" show-close="false">
<div class="baht-dialog baht-dialog--warning">
<p class="baht-dialog-message">Your draft will be lost if you leave now.</p>
<div class="baht-dialog-actions">
<baht-button id="warn-keep" variant="secondary">Keep editing</baht-button>
<baht-button id="warn-discard" variant="danger">Discard</baht-button>
</div>
</div>
</baht-modal>
<script>
const modal = document.getElementById('warn-modal');
document.getElementById('warn-open').addEventListener('click', () => (modal.open = true));
for (const id of ['warn-keep', 'warn-discard'])
document.getElementById(id).addEventListener('click', () => (modal.open = false));
modal.addEventListener('baht-close', (e) => {
if (e.detail.reason !== 'escape') modal.open = false;
});
</script>Footer with a form
Section titled “Footer with a form”.baht-modal-footer placed last inside the modal sticks to the bottom edge of the panel,
draws a hairline above and gives the buttons equal width. A long <baht-form> scrolls under
it, never past it.
Form body + sticky footer
<baht-button id="form-open">Add payment channel</baht-button>
<baht-modal id="form-modal" heading="Add payment channel" description="Used on every document.">
<baht-form id="channel-form"></baht-form>
<div class="baht-modal-footer">
<baht-button id="form-cancel" variant="secondary">Cancel</baht-button>
<baht-button id="form-save">Add channel</baht-button>
</div>
</baht-modal>
<script>
const modal = document.getElementById('form-modal');
const form = document.getElementById('channel-form');
form.form = {
form_id: 'channel', form_name: 'channel',
fields: [
{ key: 'name', type: 'text', label: 'Channel name', required: true, order: 1 },
{ key: 'bank', type: 'dropdown', label: 'Bank', order: 2,
options: [{ key: 'kbank', label: 'KBank' }, { key: 'scb', label: 'SCB' }] },
{ key: 'note', type: 'textarea', label: 'Note', order: 3 },
],
};
document.getElementById('form-open').addEventListener('click', () => (modal.open = true));
document.getElementById('form-cancel').addEventListener('click', () => (modal.open = false));
document.getElementById('form-save').addEventListener('click', () => {
if (form.validate().valid) modal.open = false;
});
modal.addEventListener('baht-close', () => (modal.open = false));
</script>Placement: bottom sheet and side drawer
Section titled “Placement: bottom sheet and side drawer”placement moves the panel; scrim, focus handling and baht-close stay the same.
center(default) — the dialog above.sheet— below 600px viewport width it docks to the bottom as a bottom sheet: full width, rounded top corners, a small grab handle, at most 90dvh with the body scrolling, slides up. From 600px up it is the centered dialog. Narrow the window to see it dock.side— a full-height drawer at the inline end,min(420px, 100vw)wide, sliding in from that edge; only the start-side corners are rounded.
Both slides are dropped under prefers-reduced-motion.
Bottom sheet (phones) and side drawer
On a phone this docks to the bottom; on a wide screen it is a centered dialog.
A full-height drawer from the end edge.
<baht-button id="sheet-open" variant="secondary">Open sheet</baht-button>
<baht-button id="side-open" variant="secondary">Open drawer</baht-button>
<baht-modal id="sheet-modal" placement="sheet" heading="Filter transactions">
<p>On a phone this docks to the bottom; on a wide screen it is a centered dialog.</p>
</baht-modal>
<baht-modal id="side-modal" placement="side" heading="Transaction detail">
<p>A full-height drawer from the end edge.</p>
</baht-modal>
<script>
for (const [btn, id] of [['sheet-open', 'sheet-modal'], ['side-open', 'side-modal']]) {
const modal = document.getElementById(id);
document.getElementById(btn).addEventListener('click', () => (modal.open = true));
modal.addEventListener('baht-close', () => (modal.open = false));
}
</script>On a phone this docks to the bottom; on a wide screen it is a centered dialog.
A full-height drawer from the end edge.
Keyboard and focus
Section titled “Keyboard and focus”- Escape closes the topmost modal only — a service dialog stacked over a modal does not take the one beneath with it.
- Tab cycles inside the panel; focus never escapes to the page behind.
- Opening moves focus to the panel; closing returns it to the element that opened it.
headingbecomes the accessible name (aria-labelledby),descriptionthearia-describedby. Without a heading the dialog is labelled “Dialog” — give it one.
| Name | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Host controls visibility. Reflected, so [open] is a styling hook |
heading | string | '' | Dialog title; also the accessible name |
description | string | '' | Muted line under the title; wired to aria-describedby |
show-close | boolean | true | Show the × button (hand-drawn cross-box illustration). show-close="false" hides it |
placement | ModalPlacement | 'center' | 'center' dialog; 'sheet' = bottom sheet below 600px, centered from 600px up; 'side' = full-height drawer at the inline end |
close-on-scrim | boolean | false | Scrim click requests close. Off by default; add the bare attribute to opt in |
Events
Section titled “Events”| Event | detail | Description |
|---|---|---|
baht-close | { reason: 'escape' | 'scrim' | 'button' } | User wants it closed. Host sets open=false — or leaves it open to block closing |
name | CustomEvent |
Methods
Section titled “Methods”requestClose()
requestClose(reason = 'button') emits baht-close with that reason — the programmatic
equivalent of the × button.
| Slot | Description |
|---|---|
(default) | Dialog body. Adopted into .baht-modal-body and kept mounted while closed |
Styling
Section titled “Styling”| Class | What it is |
|---|---|
.baht-modal-scrim |
fixed 0.4 ink overlay, no blur; --closing while the exit fade plays |
.baht-modal |
the panel — float shadow, 20px radius, role="dialog" |
.baht-modal-header / -heading / -title / -description |
header row with a hairline below |
.baht-modal-close |
36px × button |
.baht-modal-body |
where your children land |
.baht-modal-footer |
opt-in sticky action row, equal-width buttons |
.baht-modal--sheet / .baht-modal-scrim--sheet |
placement="sheet"; docked below 600px |
.baht-modal-handle |
the sheet’s decorative grab handle (shown only while docked) |
.baht-modal--side / .baht-modal-scrim--side |
placement="side" drawer |
.baht-dialog, --info/--success/--warning/--danger, -message, -detail, -input, -actions |
body classes shared with the overlay service |
Motion: opening fades the scrim and rises the panel (--baht-dur-base); closing plays the
reverse (--baht-dur-fast) and keeps .baht-modal-scrim--closing until it ends. Reopen mid-exit
and the close is cancelled. Instant under prefers-reduced-motion.