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. There is no declarative way to open a modal dialog, neither in LO-TEK nor in HTML.
That is a problem for only one kind of app: one with nowhere to make the call from, because all markup is produced on the server. 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 four are the dialog written for one environment each, and each of them is the complete recipe. The first three make the call themselves; the Datastar version lets <lt-dialog> do it, because a server that only sends HTML has nowhere to call from. The stylesheets are the same in all four, and are listed in each tab.
<!-- 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.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/dialog/dialog.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css --><button class="lt-button" data-variant="danger" id="open-confirm" type="button"> Delete the application</button>
<dialog class="lt-dialog" id="confirm" aria-labelledby="confirm-title"> <h2 class="lt-dialog__title" id="confirm-title">Delete the application?</h2> <div class="lt-dialog__body"> <p>The application and its attachments will be gone. This cannot be undone.</p> </div> <form method="dialog" class="lt-dialog__footer"> <button class="lt-button" data-variant="secondary" value="cancel">Cancel</button> <button class="lt-button" data-variant="danger" value="delete">Delete the application</button> </form></dialog>
<script type="module"> const dialog = document.getElementById("confirm")
document.getElementById("open-confirm").addEventListener("click", () => { // Escape and close() without a value leave the previous value in place. dialog.returnValue = "" dialog.showModal() })
dialog.addEventListener("close", () => { if (dialog.returnValue === "delete") { // Delete the application here } })</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 DeleteApplication({ remove }: { remove: () => 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() }} > Delete the application </button>
<dialog {...box.dialog} ref={dialog} onClose={(e) => { if (e.currentTarget.returnValue === "delete") remove() }} > <h2 {...box.title}>Delete the application?</h2> <div {...box.body}> <p>The application and its attachments will be gone. This cannot be undone.</p> </div> <form method="dialog" {...box.footer}> <button {...lt.button({ variant: "secondary" })} value="cancel">Cancel</button> <button {...lt.button({ variant: "danger" })} value="delete">Delete the application</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"> Delete the application</button>
<dialog {...box.dialog} id="confirm"> <h2 {...box.title}>Delete the application?</h2> <div {...box.body}> <p>The application and its attachments will be gone. This cannot be undone.</p> </div> <form method="dialog" {...box.footer}> <button {...lt.button({ variant: "secondary" })} value="cancel">Cancel</button> <button {...lt.button({ variant: "danger" })} value="delete">Delete the application</button> </form></dialog>
<script> const dialog = document.getElementById("confirm") as HTMLDialogElement
document.getElementById("open-confirm")?.addEventListener("click", () => { // Escape and close() without a value leave the previous value in place. dialog.returnValue = "" dialog.showModal() })
dialog.addEventListener("close", () => { if (dialog.returnValue === "delete") { // Delete the application here } })</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.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/dialog/dialog.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.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.36.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>Delete the application?</h2> <div class="lt-dialog__body"> <p>The application and its attachments will be gone. This cannot be undone.</p> </div> <form method="dialog" class="lt-dialog__footer"> <button class="lt-button" data-variant="secondary" value="cancel" data-on:click="@post('/cancel')">Cancel</button> <button class="lt-button" data-variant="danger" value="delete" data-on:click="@post('/delete')">Delete the application</button> </form> </dialog></lt-dialog>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 all four have in common
Section titled “What all four 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. The first three recipes make the call themselves; the Datastar recipe lets <lt-dialog> do it, because a server that only sends HTML has nowhere to call from. 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.
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="delete-title" open><h2 class="lt-dialog__title" id="delete-title">Delete the application?</h2><div class="lt-dialog__body"> <p>The application and its attachments will be gone. This cannot be undone.</p></div><div class="lt-dialog__footer"> <button class="lt-button" data-variant="secondary" type="button">Cancel</button> <button class="lt-button" data-variant="danger" type="button">Delete the application</button></div></dialog>When the server opens the dialog
Section titled “When the server opens the dialog”This section only applies to apps without code of their own in the browser. If you have JavaScript there, skip it: showModal() is one call, and a component in between gives you nothing.
An app in Datastar, htmx, a Go template or a Razor view has no such place. 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 decision is registered</h2> <div class="lt-dialog__body"> <p>The case is closed and has been sent to the archive.</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 markup is produced with JavaScript, lt.dialog({ titleId }) 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 “Cancel” from “Delete”. 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: "receipt-title", open: true })
<lt-dialog {...box.host}> <dialog {...box.dialog}> <h2 {...box.title}>The decision is registered</h2> <div {...box.body}>The case is closed and has been sent to the archive.</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 you write the markup in something else, import @lo-tek/interfaces and get class and for.
lt.dialog.title, lt.dialog.body and lt.dialog.footer give the class names alone. See Type-safe use.
If you produce the markup with JavaScript and control everything in the browser, you need no component: 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: "result-title", color: "success" })---<dialog {...box.dialog}> <div {...box.header}> <h2 {...box.title}>Full marks</h2> <p {...box.subtitle}>25 of 25 points</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="result-title"> <div class="lt-dialog__header"> <h2 class="lt-dialog__title" id="result-title">Full marks</h2> <p class="lt-dialog__subtitle">25 of 25 points</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”- 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: “Delete the application?”, not “Confirm”.
- The button text should say what happens. “Delete the application” 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.