Skip to content
bahtuiv0.18.8

<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

Open modal

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>

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)

Show error

The file was read, but two fields share a key.

ValidationError: fields[3].key “amount” duplicates fields[1].key
OK
<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>

Warning tone, no dismiss paths

Leave page

Your draft will be lost if you leave now.

Keep editingDiscard

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>

.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

Add payment channel
<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 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

Open sheetOpen 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>
  • 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.
  • heading becomes the accessible name (aria-labelledby), description the aria-describedby. Without a heading the dialog is labelled “Dialog” — give it one.
NameTypeDefaultDescription
openbooleanfalseHost controls visibility. Reflected, so [open] is a styling hook
headingstring''Dialog title; also the accessible name
descriptionstring''Muted line under the title; wired to aria-describedby
show-closebooleantrueShow the × button (hand-drawn cross-box illustration). show-close="false" hides it
placementModalPlacement'center''center' dialog; 'sheet' = bottom sheet below 600px, centered from 600px up; 'side' = full-height drawer at the inline end
close-on-scrimbooleanfalseScrim click requests close. Off by default; add the bare attribute to opt in
EventdetailDescription
baht-close{ reason: 'escape' | 'scrim' | 'button' }User wants it closed. Host sets open=false — or leaves it open to block closing
nameCustomEvent
  • requestClose()

requestClose(reason = 'button') emits baht-close with that reason — the programmatic equivalent of the × button.

SlotDescription
(default)Dialog body. Adopted into .baht-modal-body and kept mounted while closed
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.