Dialog
The class goes on a <dialog>, and the rest is the browser’s own element. If your app runs JavaScript in the browser, you need nothing from LO-TEK: open the dialog with showModal() and close it with close().
import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/dialog.css"Opening it must be a call. showModal() moves focus inside, keeps focus within the dialog, closes on Escape and makes the rest of the page inert. A <dialog open> in the markup does none of this, and is just a box on the page. HTML now has a way without a script, a button with commandfor and command="show-modal", which has been Baseline since December 2025. In a browser from before that the button does nothing, so the recipes open the dialog with showModal().
A click in the page makes the call with one line of script. When it is the server that decides the dialog is open, as with an answer that asks the user to confirm or a patch from Datastar, there is nowhere to make the call from. For that there is <lt-dialog>, further down the page. It is optional, in the same way that <lt-field> is optional next to lt.field().
How to use it
Section titled “How to use it”The “Try it” tab is the dialog in action. The other five are the dialog written for one environment each, and each of them is the complete recipe. Plain HTML, React, Astro and kotlinx.html open it on a click, with showModal(); the Datastar version lets the server decide that it is open, and <lt-dialog> makes the call. Each tab lists the stylesheets it needs.
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/dialog/dialog.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css --><button class="lt-button" data-variant="danger" id="open-confirm" type="button"> Cancel the job</button>
<dialog class="lt-dialog" id="confirm" aria-labelledby="confirm-title"> <h2 class="lt-dialog__title" id="confirm-title">Cancel job 222-0481?</h2> <div class="lt-dialog__body"> <p>The crew keeps the advance. This cannot be undone.</p> </div> <!-- "Keep the job" only closes the dialog. "Cancel the job" posts to the server: formmethod on the button overrides method="dialog". --> <form method="dialog" class="lt-dialog__footer"> <button class="lt-button" data-variant="secondary" value="keep">Keep the job</button> <button class="lt-button" data-variant="danger" formmethod="post" formaction="/jobs/222-0481/cancel">Cancel the job</button> </form></dialog>
<script type="module"> const dialog = document.getElementById("confirm")
// showModal() and not commandfor, which does nothing in a browser from // before December 2025. This is the one script the recipe needs. document.getElementById("open-confirm").addEventListener("click", () => { dialog.showModal() })</script>import { useRef } from "react"import { lt } from "@lo-tek/interfaces/react"
import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/dialog.css"import "@lo-tek/interfaces/button.css"
export function CancelJob({ cancel }: { cancel: () => void }) { const dialog = useRef<HTMLDialogElement>(null) const box = lt.dialog({ titleId: "confirm-title" })
return ( <> <button {...lt.button({ variant: "danger" })} type="button" onClick={() => { if (!dialog.current) return // Escape and close() without a value leave the previous value in place. dialog.current.returnValue = "" dialog.current.showModal() }} > Cancel the job </button>
<dialog {...box.dialog} ref={dialog} onClose={(e) => { if (e.currentTarget.returnValue === "cancel") cancel() }} > <h2 {...box.title}>Cancel job 222-0481?</h2> <div {...box.body}> <p>The crew keeps the advance. This cannot be undone.</p> </div> <form method="dialog" {...box.footer}> <button {...lt.button({ variant: "secondary" })} value="keep">Keep the job</button> <button {...lt.button({ variant: "danger" })} value="cancel">Cancel the job</button> </form> </dialog> </> )}---import { lt } from "@lo-tek/interfaces"
import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/dialog.css"import "@lo-tek/interfaces/button.css"
const box = lt.dialog({ titleId: "confirm-title" })---
<button {...lt.button({ variant: "danger" })} type="button" id="open-confirm"> Cancel the job</button>
<dialog {...box.dialog} id="confirm"> <h2 {...box.title}>Cancel job 222-0481?</h2> <div {...box.body}> <p>The crew keeps the advance. This cannot be undone.</p> </div> <form method="dialog" {...box.footer}> <button {...lt.button({ variant: "secondary" })} value="keep">Keep the job</button> <button {...lt.button({ variant: "danger" })} formmethod="post" formaction="/jobs/222-0481/cancel">Cancel the job</button> </form></dialog>
<script> const dialog = document.getElementById("confirm") as HTMLDialogElement
// showModal() and not commandfor, which does nothing in a browser from // before December 2025. This is the one script the recipe needs. document.getElementById("open-confirm")?.addEventListener("click", () => { dialog.showModal() })</script><!-- Datastar itself. Without it the `data-` attributes below do nothing. --><script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/dialog/dialog.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css -->
<!-- In the page template, once. Registration happens at startup, not on every patch, and it must sit outside the region the server sends again.
The address is a URL and not "@lo-tek/interfaces/dialog", because this is the path without build tools: a browser cannot resolve a package name without an import map or a build step. -->
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtDialog } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/components/frame/dialog/lt-dialog.js"
defineLtDialog()</script>
<!-- The region the server sends, and sends again on every patch. When `open` is on the host, the component calls showModal(). No client code opens anything. --><lt-dialog id="confirm" open> <dialog class="lt-dialog" open> <h2>Cancel job 222-0481?</h2> <div class="lt-dialog__body"> <p>The crew keeps the advance. This cannot be undone.</p> </div> <form method="dialog" class="lt-dialog__footer"> <button class="lt-button" data-variant="secondary" value="keep" data-on:click="@post('/jobs/222-0481/keep')">Keep the job</button> <button class="lt-button" data-variant="danger" value="cancel" data-on:click="@post('/jobs/222-0481/cancel')">Cancel the job</button> </form> </dialog></lt-dialog>On a Kotlin server you call Lt.dialog(), here with kotlinx.html and lo-tek-kotlinx-html. The dialog opens with showModal() in the browser, as in the HTML tab, for the reason at the top of the page. The choice goes to the server: “Cancel the job” posts to /jobs/222-0481/cancel, and “Keep the job” only closes the dialog.
import kotlinx.html.*import no.lotek.*import no.lotek.html.lt
fun FlowContent.cancelJob() { val box = Lt.dialog(titleId = "confirm-title") button { lt(Lt.button(variant = ButtonVariant.DANGER)); id = "open-confirm"; type = ButtonType.button; +"Cancel the job" } dialog { lt(box.dialog) id = "confirm" h2 { lt(box.title); +"Cancel job 222-0481?" } div { lt(box.body) p { +"The crew keeps the advance. This cannot be undone." } } form { lt(box.footer) attributes["method"] = "dialog" button { lt(Lt.button(variant = ButtonVariant.SECONDARY)); value = "keep"; +"Keep the job" } button { lt(Lt.button(variant = ButtonVariant.DANGER)) formMethod = ButtonFormMethod.post formAction = "/jobs/222-0481/cancel" +"Cancel the job" } } } script(type = "module") { unsafe { +""" const dialog = document.getElementById("confirm")
// showModal() and not commandfor, which does nothing in a browser from // before December 2025. This is the one script the recipe needs. document.getElementById("open-confirm").addEventListener("click", () => { dialog.showModal() }) """ } }}// Once for the whole app, in the page template. Not once per component.head { link(rel = "stylesheet", href = LotekWebJar.CSS) }body { cancelJob() script(type = "module") { unsafe { +"import { defineLt } from \"${LotekWebJar.REGISTER}\"; defineLt()" } }}If the server should open the dialog itself, put it in ltDialog { lt(box.host) } and pass open = true, as in the Datastar tab.
The heading’s id comes from the template, and the dialog takes its name from it. Make it from a key in the data, never from the loop index or a counter, so it is unique on the page and the same on every render: a screen reader follows aria-labelledby to it, and a morph recognises an element by its id. See Ids that survive an update.
The button in “Try it” does what a server would otherwise do: it sets open on <lt-dialog>, as the Datastar recipe does. The dialog in “Try it” also has a coloured header with a subtitle, described under Coloured header.
What the recipes have in common
Section titled “What the recipes have in common”Opening the dialog must be a call. showModal() moves focus inside, keeps it there, closes on Escape and makes the rest of the page inert. <dialog open> alone does none of this, and is just a box on the page. Plain HTML, React, Astro and kotlinx.html make the call on a click; the Datastar recipe lets the server decide that the dialog is open, and <lt-dialog> makes the call. Pick one of the two: inside <lt-dialog> it is open on the host that opens the dialog. A showModal() call of your own there is not seen by the component: it does not warn about it, and it closes the dialog the next time open or server-controlled on the host changes, since the host does not say open. A patch that only touches the content leaves it open.
<form method="dialog"> closes the dialog when a button is pressed, and puts the button’s value in dialog.returnValue. Then you need no listeners on the buttons, and Enter works as expected. A button with formmethod="post" submits the form to its formaction instead, and the dialog stays open until the next page arrives, as “Cancel the job” does in the Plain HTML, Astro and kotlinx.html recipes. In the React and Datastar recipes it closes the dialog with value="cancel", and the app or the server cancels the job.
Escape closes the dialog on its own. To prevent that, for example in the middle of a save, cancel the cancel event. Do it rarely: a dialog that cannot be closed is a trap.
Without showModal()
Section titled “Without showModal()”Without showModal() the markup is a box on the page. That is the state the server sends, and the one you see if JavaScript never runs.
<dialog class="lt-dialog" aria-labelledby="cancel-title" open><h2 class="lt-dialog__title" id="cancel-title">Cancel job 222-0481?</h2><div class="lt-dialog__body"> <p>The crew keeps the advance. This cannot be undone.</p></div><div class="lt-dialog__footer"> <button class="lt-button" data-variant="secondary" type="button">Keep the job</button> <button class="lt-button" data-variant="danger" type="button">Cancel the job</button></div></dialog>When the server opens the dialog
Section titled “When the server opens the dialog”This section applies when the server decides that the dialog is open, not a click in the browser: an answer that asks the user to confirm, or a patch from Datastar or htmx. When a click opens it, as in the recipes above, showModal() is one call, and a component in between gives you nothing.
The server has nowhere to call showModal() from. It sends open on <lt-dialog>, and the component makes the call.
import { defineLtDialog } from "@lo-tek/interfaces/dialog"
defineLtDialog()<lt-dialog open> <dialog class="lt-dialog" open> <h2>The order is placed</h2> <div class="lt-dialog__body"> <p>Job 222-0481 is open to crews.</p> </div> <form method="dialog" class="lt-dialog__footer"> <button class="lt-button" value="close">Close</button> </form> </dialog></lt-dialog>Inside <lt-dialog> the component names the dialog from the first heading outside the body: aria-labelledby pointing at the heading’s id, which it creates if it is missing, and the class lt-dialog__title on the heading. A heading inside lt-dialog__body belongs to the content, not to the dialog, and never becomes the title. If the server wrote aria-labelledby or aria-label, that stays. If the dialog has neither and no heading outside the body either, the component warns in the console. When the code that renders the markup can call a builder function, lt.dialog({ titleId }) or Lt.dialog() writes the same.
Give the button that closes the dialog a value. Without JavaScript the dialog is a box on the page, and <form method="dialog"> closes it before any script has run. The component recognises what has already happened from returnValue, which the browser sets to the button’s value. If it is empty, the component sees a host that says “open” and a closed dialog, and opens it again.
When the user closes the dialog, with Escape or with a button in <form method="dialog">, the component removes open from <lt-dialog> again, so the markup says the same as the screen. A click on the backdrop does not close it; neither does it on a plain <dialog>. The component reports with the dialog-toggle event, which has detail.open and detail.returnValue, the value of the button that closed it. That is how you tell “Keep the job” from “Cancel the job”. The component clears the value every time it opens the dialog, so a dialog closed without a button, with Escape or from the server, reports an empty value and not the one from last time.
The two open attributes in the example are not the same:
| Where | Set by | What happens in an update |
|---|---|---|
On <lt-dialog> | The server, when the dialog is to be shown | The server decides. If it sends open again, the dialog opens again |
On <dialog> | The server when the dialog is to be shown, and the browser in showModal() | The component puts it back as long as the dialog is in the top layer |
server-controlled on <lt-dialog> turns off the latter. Then every update decides whether the dialog is shown, also while it is in the top layer.
That the host follows the server is a choice and not an oversight. If the server sends the region again with open still set, the dialog opens again; for a dismissal to last, the app has to tell the server, as for any other state the server decides.
The attribute on the <dialog> itself is something else: the browser sets it in showModal(), and the server did not send it, so an update tears it off and hides the dialog the moment the component opened it. The component puts it back while the dialog is still in the top layer, so your template needs nothing extra.
The server still writes it when the dialog is to be shown, and lt.dialog({ titleId, open: true }) puts it on both elements. The reason is that a <dialog> without open is hidden: without JavaScript, the content the server meant to show would not be there at all.
Inside <lt-dialog> such a dialog is styled as a modal before it is one: centred in the window, with the area behind it painted by a shadow, since ::backdrop only exists in the top layer. It also has the same maximum width the browser gives a modal. The geometry is then the same in both states, and the dialog stays still when the component calls showModal(), also on a page that takes time to load. The layer can be set with --lt-dialog-layer.
One caveat: the dialog is fixed-positioned before it becomes modal, while a real modal sits in the top layer. If it is inside an element with transform, filter or contain: layout, that element becomes the containing block for the first, and the window for the second. Then it jumps after all, and how far depends on where that element is. Keep the dialog outside such elements.
None of this applies outside the host. A .lt-dialog with open and no <lt-dialog> around it stays where it is in the flow, as the browser’s own style places it.
// src/lotek.d.ts, gives <lt-dialog> types in JSXimport "@lo-tek/interfaces/react-jsx"import { lt } from "@lo-tek/interfaces/react"
const box = lt.dialog({ titleId: "order-title", open: true })
<lt-dialog {...box.host}> <dialog {...box.dialog}> <h2 {...box.title}>The order is placed</h2> <div {...box.body}>Job 222-0481 is open to crews.</div> <form method="dialog" {...box.footer}> <button {...lt.button()} value="close">Close</button> </form> </dialog></lt-dialog>The import goes to /react here, since the example is JSX. From there the keys are className and htmlFor. If the markup is not JSX, import @lo-tek/interfaces and get class and for, or call Lt.dialog() on a Kotlin or Java server.
lt.dialog.title, lt.dialog.body and lt.dialog.footer give the class names alone. See Type-safe use.
If the app has code in the browser that calls showModal(), you need no component, whatever rendered the markup: call showModal() yourself, as in the “Plain HTML” recipe above. Then do not pass open to lt.dialog(). The attribute is the server’s message to <lt-dialog>, and showModal() throws InvalidStateError on a dialog that is already open.
In React the attribute is also the only thing that matches when the server renders the dialog open. The component sets open on <dialog> before React hydrates, so if it was not in the server’s HTML, React reported a mismatch on every single page load.
Coloured header
Section titled “Coloured header”When the dialog should say how things went before the reader has read a word, it gets a coloured header. lt.dialog({ titleId, color }) gives data-color on the dialog and a header part that wraps the heading. The colours are the same as the alert uses, with brand in addition for the brand colour.
color | Means |
|---|---|
neutral | No colour. Default |
brand | The brand colour, for a header that says who is speaking |
info | For information |
success | It went well |
warning | Look at this |
danger | Something is wrong, or cannot be undone |
---const box = lt.dialog({ titleId: "delivered-title", color: "success" })---<dialog {...box.dialog}> <div {...box.header}> <h2 {...box.title}>Job 222-0479 delivered</h2> <p {...box.subtitle}>Clean, before the deadline</p> </div> <div {...box.body}>…</div> <form method="dialog" {...box.footer}>…</form></dialog>Without the builder function it is the same classes, written out:
<dialog class="lt-dialog" data-color="success" aria-labelledby="delivered-title"> <div class="lt-dialog__header"> <h2 class="lt-dialog__title" id="delivered-title">Job 222-0479 delivered</h2> <p class="lt-dialog__subtitle">Clean, before the deadline</p> </div> <div class="lt-dialog__body">…</div> <form method="dialog" class="lt-dialog__footer">…</form></dialog>The header is optional, and without it the dialog looks as before. The colour applies to the header, so without header in the markup it has nothing to colour. A header without colour is not a stripe either: it then drops its bottom padding, and the distance down to the text is the same as otherwise.
Attributes
Section titled “Attributes”| Attribute | Type | Effect |
|---|---|---|
open | boolean | Whether the dialog is shown. The server sets it when the dialog is to be shown, and the component removes it when the user closes it. For the server to show the dialog, open must also be on <dialog>. |
server-controlled | boolean | If an update from the server removes open from <dialog> while the dialog is open, the component closes it and sends dialog-toggle. Without the attribute it puts open back. See When the server opens the dialog. |
Classes you can style
Section titled “Classes you can style”| Class | What it is |
|---|---|
.lt-dialog | The dialog itself. data-color colours the header |
.lt-dialog__header | The header, with the heading and an optional subtitle inside. Optional: with it, the padding moves from the dialog to the parts, so a coloured band reaches all the way to the edges |
.lt-dialog__title | The heading |
.lt-dialog__subtitle | A line below the heading, inside the header |
.lt-dialog__body | The content, which scrolls when the dialog gets too tall. Must be a direct child of .lt-dialog, otherwise the dialog scrolls instead, and the heading and the button row scroll out of view with the text |
.lt-dialog__footer | The row of buttons at the bottom |
display is the one property you cannot set freely on .lt-dialog. A <dialog> without open is hidden by the browser’s own stylesheet, and an author rule with display beats it regardless of layer and specificity, so a closed dialog stays on the page as a card. If you need a different layout, add dialog.lt-dialog:not([open]):not(:is(:popover-open)) { display: none } yourself, and make it at least as specific as your own rule. The system sets display: flex only when .lt-dialog__body is a direct child, and does exactly this alongside it.
| Variable | Default |
|---|---|
--lt-dialog-width | min(32rem, 100%, calc(100vw - var(--lt-spacing-8))) |
--lt-dialog-padding | var(--lt-spacing-5) |
--lt-dialog-radius | var(--lt-spacing-2) |
--lt-dialog-layer | 40 |
--lt-dialog-layer only applies in the brief moment the dialog is open without being modal, before the component has called showModal(). It is not in the top layer yet then, and must be stacked like everything else. Once the dialog is modal, it is above everything regardless.
100% in the width applies when the dialog is not in the top layer. A <dialog> opened with showModal() relates to the viewport regardless, but if you make a box on the page of it, as in the preview above, 100vw would make it wider than the box.
The area behind the dialog is ::backdrop, and only exists when the dialog was opened with showModal(). The colour comes from --lt-color-overlay and is stronger in the dark theme, where a black overlay would otherwise be invisible.
See Customising for how the variables and the layer work.
Accessibility
Section titled “Accessibility”
What a screen reader is given when the recipe above is used, step by step. It is checked through AT-SPI, the
interface Orca reads, in Chromium and Firefox, on every change, by check-accessibility-tree.ts, and
every change the check lists as a mutation must make it fail.
- Focus
#open-confirm: focus on thebutton"Cancel the job". - Press Enter: focus on the
button"Keep the job"; inside thedialog"Cancel job 222-0481?", modal. - Press Escape: focus on the
button"Cancel the job".
Where a browser does not do what the specification says, which the check requires until the browser changes:
- Firefox: Firefox 150 (Playwright firefox-1522), 2026-10-29: a <dialog> opened with showModal() has no STATE_MODAL, which Core-AAM maps from the modal dialog's implicit aria-modal.
- The dialog needs a name.
lt.dialog({ titleId })writesaria-labelledbypointing at the heading, and inside<lt-dialog>the component sets it from the first heading outside the body when it is missing. If you have no heading, usearia-label. - The heading should ask the question: “Cancel job 222-0481?”, not “Confirm”.
- The button text should say what happens. “Cancel the job” is clearer than “OK”, especially for someone who hears the button without seeing the heading.
- After the dialog closes, the browser returns focus to the button that opened it. If you move focus yourself in the meantime, you have to put it back.
- Do not use a dialog for something that could have been on the page. It takes over the whole screen for someone using magnification.
<lt-dialog>always opens modally. Ashow()would give a box that looks the same, but does not trap focus and does not close on Escape. The difference is:modal, and it is tested in all three browser engines.