Modal
Centered dialog with a dimmed backdrop. Closes on Escape and on backdrop click, locks body scroll while open, restores it on close. Built entirely from data-if and a single document-level listener — no portal, no library.
Live preview
Delete project?
This will permanently delete the project and revoke API
keys. The action cannot be undone.
Markup
<div data-component="modal-demo">
<button @click="open">Open dialog</button>
<div class="modal-backdrop" data-if="visible" @click.self="cancel">
<div class="modal-dialog" role="dialog" aria-modal="true">
<header class="modal-header">
<h3>Delete project?</h3>
<button class="icon-btn" aria-label="Close" @click="cancel">×</button>
</header>
<div class="modal-body">Confirmation message…</div>
<footer class="modal-footer">
<button @click="cancel">Cancel</button>
<button class="btn-primary" @click="confirm">Delete</button>
</footer>
</div>
</div>
</div>
Definition
Micra.define('modal-demo', {
state: { visible: false, lastAction: '' },
onCreate() {
this._onKey = (e) => {
if (e.key === 'Escape' && this.state.visible) this.cancel()
}
document.addEventListener('keydown', this._onKey)
},
onDestroy() {
document.removeEventListener('keydown', this._onKey)
document.body.style.overflow = ''
},
open() {
this.state.visible = true
document.body.style.overflow = 'hidden' // lock scroll
},
// close() is the bare close — does NOT touch lastAction so confirm()
// can stamp 'confirmed' first and then dismiss.
close() {
this.state.visible = false
document.body.style.overflow = ''
},
cancel() {
this.state.lastAction = 'cancelled'
this.close()
},
confirm() {
this.state.lastAction = 'confirmed'
this.close()
},
})
Integration
- Backdrop click closes. @click.self="close" fires only when the click target is the backdrop itself — clicks inside the dialog don't bubble through.
- Escape closes. Single document-level listener in onCreate; removed in onDestroy so a destroyed instance can't intercept stray Escape presses.
- Body scroll lock. document.body.style.overflow = 'hidden' while open. Always reset in close AND onDestroy so the page can't end up frozen if the instance is torn down with the modal open.
- Slot content. Any markup inside the dialog renders normally — bind values with data-text or pass props via data-* + this.prop().
- Bus events. Emit Micra.emit('modal:open', payload) from another component to drive the modal — combine with this.on() in onCreate for cross-component control.
Pitfalls
- Don't use data-show for the backdrop — keyboard focus would still reach the hidden buttons inside. data-if removes them from the tab order entirely.
- Don't attach Escape with addEventListener inside open() without removing it on close — listeners stack. The pattern above attaches once in onCreate and gates on visible.
- Don't put a setTimeout-driven animation that mutates state from outside Micra's render cycle. Use CSS transitions on a static class, or animate via data-class bound to state.