Dialog
<oblyx-dialog> is a centered modal built on the native <dialog> element and showModal(). Pair it with <oblyx-dialog-header>,<oblyx-dialog-body> and <oblyx-dialog-footer> for a title, a scrolling body and an action row. Open and close it with a plain <button>'s command/commandfor attributes, no script required, as in the example below. A scripted route also exists, for a consumer who needs it: see "Opening from script" further down this page.
Basic dialog
<button type="button" command="--show" commandfor="ox-doc-dialog-basic">
Delete project
</button>
<oblyx-dialog id="ox-doc-dialog-basic" label="Delete project?">
<oblyx-dialog-header heading="Delete project?"></oblyx-dialog-header>
<oblyx-dialog-body>This can't be undone. All boards, cards and history are removed immediately.</oblyx-dialog-body>
<oblyx-dialog-footer>
<button type="button" command="--close" commandfor="ox-doc-dialog-basic">Cancel</button>
<oblyx-button variant="destructive">Delete</oblyx-button>
</oblyx-dialog-footer>
</oblyx-dialog>button {
attributes["command"] = "--show"
attributes["commandfor"] = "ox-doc-dialog-basic"
+"Delete project"
}
oblyxDialog(label = "Delete project?") {
id = "ox-doc-dialog-basic"
oblyxDialogHeader(heading = "Delete project?")
oblyxDialogBody { +"This can't be undone. All boards, cards and history are removed immediately." }
oblyxDialogFooter {
// A plain <button> here, not oblyxButton: this row needs the
// command/commandfor pair to close the dialog, and oblyx-button has no
// attribute of its own to carry them.
button {
attributes["command"] = "--close"
attributes["commandfor"] = "ox-doc-dialog-basic"
+"Cancel"
}
oblyxButton(variant = OblyxButtonVariant.DESTRUCTIVE) { +"Delete" }
}
}The declarative trigger
command="--show" and commandfor="ox-doc-dialog-basic" are the platform's own Invoker Commands API: commandfor names the dialog by id, and command carries the action. <oblyx-dialog> understands two command values, both custom (the double-dash prefix is the platform's own naming rule for an author-defined command, distinct from a browser built-in): --show opens it, and --close dismisses it through the same cancelable path as Escape, a backdrop click, and the header's own close button (see "Opening and dismissing" below). The browser wires the click to the dialog entirely on its own. Nothing here is Oblyx-specific markup: the same command/commandfor pair works on a plain native <dialog> too.
This relies on browser support for Invoker Commands, which reached Baseline availability across Chrome, Edge, Firefox and Safari only recently (Chrome/Edge 135+, Firefox 144+, Safari 26.2+). That is consistent with the rest of this library, which already depends on equally recent platform features with no polyfill: the Popover API, CSS anchor positioning, and <details name>. A browser without support treats command/commandfor as ordinary, inert attributes: the button renders and is focusable, it just does not open anything on click. A consumer who must support such a browser should use the scripted route instead.
Composition
oblyx-dialog-header, oblyx-dialog-body and oblyx-dialog-footer are plain child elements, not slots, arranged by CSS in DOM order: the same composition model as card. Any subset, any order. The header is the one region with a real job beyond layout: its heading becomes the dialog's visible title and its accessible name (via aria-labelledby), and it renders a close button by default. A dialog with no header must set label directly on <oblyx-dialog> instead: one of the two is required, and a missing one is logged to the console rather than silently shipping an unlabeled dialog.
Opening and dismissing
Setting dismissible to false (default true) turns off every ambient way to close the dialog at once: Escape, a backdrop click, and the header's own close button all become no-ops. There's no separate flag for each: a dialog that shouldn't lose its input to a stray Escape shouldn't lose it to a stray backdrop click either. A command="--close" invoker button is subject to the same gate, since it also routes through the cancelable path. The only way out of a non-dismissible dialog is a footer action that sets open = false directly (bypassing that gate on purpose, for a genuine "save and close" action), or a cancel event listener that calls event.preventDefault() conditionally (to build something like a "discard changes?" confirmation) and lets it through otherwise.
dismissible and the header's own closable both default to true, so bare presence (or omitting the attribute) means true, matching a native disabled="". Writing the literal string dismissible="false" in server-rendered markup turns it off, same as the example below: no client-side script required.
Escape is intercepted at keydown, not through the native <dialog>cancel event: in Chromium that event reports cancelable: false, so preventDefault() on it does nothing and the platform closes the dialog regardless. The cancel listener stays wired as a fallback, but the real interception point (and the one this library's own dismissible gate depends on) is the keydown handler. A backdrop click is detected by comparing event.target to event.currentTarget: a real ::backdrop click always targets the <dialog> itself, since the backdrop isn't a child node, while a click on any actual content inside targets that content instead.
Not dismissible
No Escape, no backdrop click, no close button, and no command="--close" invoker either: every ambient path is gated the same way. Only the footer's own Confirm button, setting open = false directly, closes it.
<button type="button" command="--show" commandfor="ox-doc-dialog-required">
Start required step
</button>
<oblyx-dialog id="ox-doc-dialog-required" dismissible="false" label="Confirm your email">
<oblyx-dialog-header heading="Confirm your email" closable="false"></oblyx-dialog-header>
<oblyx-dialog-body>Enter the code we sent before continuing.</oblyx-dialog-body>
<oblyx-dialog-footer>
<button type="button" onclick="document.getElementById('ox-doc-dialog-required').open = false">
Confirm
</button>
</oblyx-dialog-footer>
</oblyx-dialog>button {
attributes["command"] = "--show"
attributes["commandfor"] = "ox-doc-dialog-required"
+"Start required step"
}
oblyxDialog(dismissible = false, label = "Confirm your email") {
id = "ox-doc-dialog-required"
oblyxDialogHeader(heading = "Confirm your email", closable = false)
oblyxDialogBody { +"Enter the code we sent before continuing." }
oblyxDialogFooter {
// A plain <button> here, not oblyxButton: this row needs an inline
// onclick that sets open = false directly, bypassing the dismissible
// gate on purpose (a genuine "confirmed, now close" action, not an
// ambient dismissal) - command="--close" would route through the same
// cancelable path Escape and a backdrop click use, and dismissible =
// false blocks that path too, by design.
button {
onClick = "document.getElementById('ox-doc-dialog-required').open = false"
+"Confirm"
}
}
}Focus
Focus trapping and Tab-cycling inside the dialog come from the platform: showModal() makes the rest of the document inert for as long as the dialog is open, so Tab can never leave it. Nothing in Oblyx re-implements that.
What Oblyx does own is where focus lands and where it goes back to. On open, focus moves to [data-autofocus] if one exists inside the dialog, otherwise to the header's title (so a screen reader announces it immediately), otherwise to the browser's own default (the first focusable descendant, or the dialog itself). On close, focus always returns to whatever element triggered the open, whether that was a command="--show" button or a scripted click handler, and only after dialog.close() has actually run, since the rest of the page is still inert until that call returns.
Autofocus a specific field
data-autofocus wins over the header title.
<button type="button" command="--show" commandfor="ox-doc-dialog-autofocus">
Rename board
</button>
<oblyx-dialog id="ox-doc-dialog-autofocus" label="Rename board">
<oblyx-dialog-header heading="Rename board"></oblyx-dialog-header>
<oblyx-dialog-body>
<label for="ox-doc-dialog-autofocus-input">Board name</label>
<input id="ox-doc-dialog-autofocus-input" data-autofocus type="text" value="Q3 roadmap" />
</oblyx-dialog-body>
<oblyx-dialog-footer>
<oblyx-button variant="primary">Save</oblyx-button>
</oblyx-dialog-footer>
</oblyx-dialog>button {
attributes["command"] = "--show"
attributes["commandfor"] = "ox-doc-dialog-autofocus"
+"Rename board"
}
oblyxDialog(label = "Rename board") {
id = "ox-doc-dialog-autofocus"
oblyxDialogHeader(heading = "Rename board")
oblyxDialogBody {
label {
htmlFor = "ox-doc-dialog-autofocus-input"
+"Board name"
}
input(type = InputType.text) {
id = "ox-doc-dialog-autofocus-input"
attributes["data-autofocus"] = ""
value = "Q3 roadmap"
}
}
oblyxDialogFooter {
oblyxButton(variant = OblyxButtonVariant.PRIMARY) { +"Save" }
}
}Loading dialog content with HTMX
A common shape in a server-rendered app: the trigger both opens the dialog and issues an hx-get that swaps real content into <oblyx-dialog-body>, instead of the page shipping every dialog's full content up front. Opening the dialog and starting the fetch are two independent actions on the same click, driven by two independent attribute pairs on the same button: command/commandfor shows the dialog immediately with a loading state, and hx-get/hx-target/hx-swap swap the fetched fragment in whenever the response arrives, wherever that lands relative to the animation.
<oblyx-dialog-body> is exactly as independently constructible as every other Oblyx child element (see BRAINSTORM.md's "fragments render partial subtrees" rule), so the swapped-in response can be a bare fragment with no <oblyx-dialog> wrapper at all: HTMX upgrades it in place the moment it lands.
The example below performs a real hx-get, a real fetch and a real DOM swap: open your browser's network panel and watch it happen. The response is a fixed file on this site rather than something a server rendered for that specific request, since this docs site has no backend of its own. The fragment contains a real <oblyx-checkbox>, unstyled and inert until the swap lands, so you can watch it upgrade into a working control the moment HTMX inserts it.
The HTML tab's hx-get path points at that static file so the live preview below actually works; the Kotlin tab uses /fragments/delete-project-body instead, a route path a real Ktor server would serve dynamically rather than a static asset path.
Dialog body loaded on demand
Watch the network panel: the checkbox below arrives as plain markup and upgrades on arrival.
<button
type="button"
command="--show"
commandfor="ox-doc-dialog-htmx"
hx-get="/examples/overlays/delete-project-body.html"
hx-target="#ox-doc-dialog-htmx-body"
hx-swap="innerHTML"
>
Delete project (loads content)
</button>
<oblyx-dialog id="ox-doc-dialog-htmx" label="Delete project?">
<oblyx-dialog-header heading="Delete project?"></oblyx-dialog-header>
<oblyx-dialog-body id="ox-doc-dialog-htmx-body">Loading…</oblyx-dialog-body>
<oblyx-dialog-footer>
<oblyx-button variant="destructive">Delete</oblyx-button>
</oblyx-dialog-footer>
</oblyx-dialog>button {
attributes["command"] = "--show"
attributes["commandfor"] = "ox-doc-dialog-htmx"
attributes["hx-get"] = "/fragments/delete-project-body"
attributes["hx-target"] = "#ox-doc-dialog-htmx-body"
attributes["hx-swap"] = "innerHTML"
+"Delete project (loads content)"
}
oblyxDialog(label = "Delete project?") {
id = "ox-doc-dialog-htmx"
oblyxDialogHeader(heading = "Delete project?")
oblyxDialogBody { id = "ox-doc-dialog-htmx-body"; +"Loading…" }
oblyxDialogFooter {
oblyxButton(variant = OblyxButtonVariant.DESTRUCTIVE) { +"Delete" }
}
}Opening from script
The open property/attribute is the underlying mechanism the declarative trigger itself sets: setting it directly still works, and is the right choice when a consumer needs to support a browser without Invoker Commands, or needs to run other logic (an analytics call, a conditional guard) at the moment of opening. Nothing about the declarative trigger requires this route, and nothing about this route requires the declarative trigger: pick whichever a given dialog needs, independently, dialog by dialog.
Opened from an inline click handler
Equivalent to the basic dialog above, opened with a property assignment instead of command/commandfor.
<button type="button" onclick="document.getElementById('ox-doc-dialog-scripted').open = true">
Delete project
</button>
<oblyx-dialog id="ox-doc-dialog-scripted" label="Delete project?">
<oblyx-dialog-header heading="Delete project?"></oblyx-dialog-header>
<oblyx-dialog-body>This can't be undone.</oblyx-dialog-body>
<oblyx-dialog-footer>
<oblyx-button variant="destructive">Delete</oblyx-button>
</oblyx-dialog-footer>
</oblyx-dialog>button {
onClick = "document.getElementById('ox-doc-dialog-scripted').open = true"
+"Delete project"
}
oblyxDialog(label = "Delete project?") {
id = "ox-doc-dialog-scripted"
oblyxDialogHeader(heading = "Delete project?")
oblyxDialogBody { +"This can't be undone." }
oblyxDialogFooter {
oblyxButton(variant = OblyxButtonVariant.DESTRUCTIVE) { +"Delete" }
}
}Reference
<oblyx-dialog>
| Attribute | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Whether the dialog is open. Setting this directly always takes effect: it is the "force open/closed" path, matching a native <dialog>'s own close(). For a dismissal a user can cancel, see dismiss. |
dismissible | boolean | true | Whether Escape and a backdrop click attempt to close the dialog at all. |
label | string | undefined | – | Accessible name, used when no <oblyx-dialog-header heading> (or <oblyx-drawer>'s own light content) provides one. Required in that case; its absence is flagged loudly, not silently ignored. |
Events
| Event | Type | Description |
|---|---|---|
close | Event | Fired once the dialog has finished closing: its exit animation has ended, the underlying dialog element has closed, and focus has returned to whatever opened it. |
cancel | Event | Fired when the user attempts to dismiss the dialog through Escape, a backdrop click, or a built-in close control. Cancelable: call preventDefault on this event to keep the dialog open. |
<oblyx-dialog-header>
| Attribute | Type | Default | Description |
|---|---|---|---|
heading | string | undefined | – | The dialog's visible title, and also its accessible name source. Optional only because a header might exist purely to hold a close button/actions row with the title supplied elsewhere via the dialog's own label; a header with neither is a defect and is flagged loudly. |
subtitle | string | undefined | – | Secondary line under the title. |
closable | boolean | true | Whether to render the built-in close (×) button. |
close-label | string | Close | Accessible name for the close button. |
<oblyx-dialog-body>
This element declares no attributes.
<oblyx-dialog-footer>
This element declares no attributes.