Co-authored-by: Copilot <copilot@github.com>
8.2 KiB
Dialog
A modal dialog using the native HTML <dialog> element. Content is organized into optional title, description, body, and footer sections. Open/close is handled by client-side JS via delegated click events on data-dialog-open and data-dialog-close attributes.
HTML structure
dialog[id, class=...]
div.dialog-panel.relative.bg-background.p-6.rounded-lg.shadow-xl.w-full.max-w-md...
button.absolute.top-4.right-4[data-dialog-close={id}] ← × close button
h2.text-lg.font-semibold ← title (omitted when empty)
p.text-sm.text-muted-foreground.mt-1 ← description (omitted when empty)
div.mt-4 ← body content
{content}
div.mt-6.flex.justify-end.gap-2 ← footer (omitted when empty)
{footer}
CSS mechanics
| Class | Effect |
|---|---|
dialog::backdrop (in input.css) |
Semi-transparent black overlay behind the dialog |
animate-in fade-in-0 zoom-in-95 |
CSS entry animation when dialog opens |
max-w-md w-full |
Responsive: full width on small screens, capped at md |
overflow-y-auto max-h-[90vh] |
Scrollable body for tall content |
JavaScript (delegated clicks in components.js)
Set up once on document and works for all dialogs on the page, including those HTMX-swapped in.
Open
Any element with data-dialog-open="myDialogId" calls document.getElementById('myDialogId').showModal().
Close
Any element with data-dialog-close="myDialogId" calls document.getElementById('myDialogId').close().
The × close button inside the dialog panel already has data-dialog-close set to the dialog's id.
Clicking the ::backdrop (outside the panel) also closes the dialog — the click handler checks whether the click target is the <dialog> element itself.
Constructor signature
public Dialog(
string id,
string content,
string title = "",
string description = "",
string footer = "")
| Parameter | Description |
|---|---|
id |
Element id — must be unique per page; also used by data-dialog-open |
content |
Raw HTML for the dialog body |
title |
Optional heading at the top of the panel |
description |
Optional subheading below the title |
footer |
Optional raw HTML for the bottom button row |
Usage examples
Simple information dialog
// In the page component:
Dialog = new Dialog(
id: "about-dialog",
title: "About BeepBoop",
description: "A fast AOT-safe HTMX framework.",
content: "<p>Version 1.0 — built with ❤️ and .NET 10.</p>",
footer: """<button data-dialog-close="about-dialog" class="...">Close</button>""");
Trigger button anywhere on the page:
<button data-dialog-open="about-dialog" class="...">About</button>
Confirmation dialog
new Dialog(
id: "confirm-delete",
title: "Delete item",
content: "<p>This action cannot be undone.</p>",
footer: """
<button data-dialog-close="confirm-delete" class="...">Cancel</button>
<button hx-delete="/items/42" hx-confirm="" data-dialog-close="confirm-delete"
class="bg-destructive text-destructive-foreground ...">
Delete
</button>
""")
HTMX-powered content reload
new Dialog(
id: "user-detail",
content: """<div hx-get="/users/42/detail" hx-trigger="revealed"></div>""")
The revealed trigger fires when the dialog becomes visible, loading content on demand.
Embedding inside a page slot
<!-- MyPage.htmx -->
$$DeleteDialog$$
<button data-dialog-open="confirm-delete" class="...">Delete</button>
public IHtmxComponent DeleteDialog { get; }
public MyPage()
{
DeleteDialog = new Dialog(
id: "confirm-delete",
title: "Confirm deletion",
content: "<p>Are you sure?</p>",
footer: """<button data-dialog-close="confirm-delete">Cancel</button>""");
}
protected override void RenderDeleteDialog(HtmxRenderContext ctx)
=> DeleteDialog.Render(ctx.Next());
Tips and tricks
- The
idis used both on the<dialog>element and indata-dialog-open/data-dialog-close— keep it unique per page. - The
×close button is always rendered;data-dialog-closeon footer buttons is optional but improves UX. - Use the native
<dialog>closeevent for any cleanup needed after dismissal:document.getElementById('id').addEventListener('close', fn). - Dialog content, title, description, and footer are raw HTML — HTML-encode user-supplied values.
- For dialogs that load content via HTMX, place the HTMX attributes on a child div inside
contentrather than on the<dialog>element itself to avoid interfering with the dialog open/close lifecycle. - For dialogs that load content via HTMX, place the HTMX attributes on a child div inside
contentrather than on the<dialog>element itself to avoid interfering with the dialog open/close lifecycle.
Complete page example
Templates/ItemsPage.htmx
<div class="max-w-3xl mx-auto py-10">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Items</h1>
</div>
$$ItemsTable$$
$$DeleteDialog$$
</div>
Templates/ItemsPage.htmx.cs
namespace Htmx.ApiDemo.Templates;
public sealed class ItemsPage : ItemsPageBase
{
private readonly IHtmxComponent _table;
private readonly IHtmxComponent _deleteDialog;
public ItemsPage(IEnumerable<Item> items, string? deleteTargetId = null)
{
// Build table with a per-row Delete button that opens the dialog
_table = new Components.Table(
headers: new[] { "Name", "Created", "Actions" },
rows: items.Select(item => new[]
{
System.Net.WebUtility.HtmlEncode(item.Name),
item.CreatedAt.ToShortDateString(),
$"""<button data-dialog-open="confirm-delete"
hx-on:click="document.getElementById('delete-id').value='{item.Id}'"
class="text-sm text-destructive underline">Delete</button>""",
}));
_deleteDialog = new Components.Dialog(
id: "confirm-delete",
title: "Delete item",
content: """
<p class="text-sm">This action cannot be undone.</p>
<input type="hidden" id="delete-id" name="itemId">
""",
footer: """
<button data-dialog-close="confirm-delete"
class="inline-flex h-9 rounded-md border border-input px-4 text-sm mr-2">
Cancel
</button>
<button hx-delete="/items"
hx-include="#delete-id"
hx-target="closest tr"
hx-swap="outerHTML"
data-dialog-close="confirm-delete"
class="inline-flex h-9 rounded-md bg-destructive text-destructive-foreground px-4 text-sm">
Delete
</button>
""");
}
protected override void RenderItemsTable(HtmxRenderContext ctx) => _table.Render(ctx.Next());
protected override void RenderDeleteDialog(HtmxRenderContext ctx) => _deleteDialog.Render(ctx.Next());
}
GET + DELETE handlers
[Handler]
[MapGet("/items")]
public static partial class GetItemsHandler
{
public record Query();
private static Task<IResult> HandleAsync(
Query _, HttpContext ctx, CancellationToken ct)
{
var items = new[]
{
new Item("1", "Widget A", DateTime.Today.AddDays(-10)),
new Item("2", "Widget B", DateTime.Today.AddDays(-5)),
};
return ctx.WriteHtmxPage(new ItemsPage(items), title: "Items");
}
}
[Handler]
[MapDelete("/items")]
public static partial class DeleteItemHandler
{
public record Command([property: FromForm] string ItemId);
private static IResult HandleAsync([AsParameters] Command cmd, CancellationToken ct)
{
// Delete item from database…
return Results.Ok(); // HTMX swaps the row out with outerHTML
}
}
public record Item(string Id, string Name, DateTime CreatedAt);