BLACKFYRE
MG—01
LANG
←
ALL POSTS
05 · Writing / 2023
14 OCT 2023 · 5 MIN READ

Using the native dialog with HTMX

An empty <dialog>, a fragment from the server and an HX-Trigger header: modal windows with htmx and no modal library.

CONTENTS
06 +

Recently I’ve found out about htmx and immediately fell in love with the concept of ditching frontend frameworks for ~90% of the use cases I deal with on the regular. Alas, there are things we still need additional help with, but thankfully htmx is very helpful in that regard as it’s extensible and works well with other scripts and even with frameworks.

My other fixation is using browser native features as much as possible (they are there for a reason…). While this is not something shared by UI designers, it’s more often easier to have the design adjusted than finding a library that supports both the design and business requirements when there’s a crunch.

And after this long preamble, the star of the show: the browser native dialog, which can easily be the best solution for modal windows so far!

The examples come from the Web Gallery of Art rebuild I’ve just started, where visitors can send a painting to someone as a postcard.

The dialog

layout.html
HTML
1<html>
2 <body>
3 <a href="#" hx-on="click: document.getElementById('d').showModal();"
4 hx-get="/postcard/send?awid={{.Id}}" hx-target="#d" class="card-footer-item">Send
5 Postcard</a>
6 <dialog id="d"></dialog>
7 </body>
8</html>

To get started, you only need to add a <dialog> element with an ID attribute, as seen in the above code snippet. It sits empty in the layout, once, and every modal on the site reuses it.

The link does two things on the same click: htmx fetches the postcard editor and swaps it into #d, and the hx-on handler calls showModal(). That one call gives you a lot for free:

  • the dialog is rendered in the top layer, above everything else, with no z-index wars;
  • the rest of the page becomes inert, so clicks and tabbing stay inside the modal;
  • Esc closes it;
  • the ::backdrop pseudo-element is there to style.

The content

The server answers with a fragment, which htmx drops into the dialog:

partials/postcard.html
HTML
1<section class="postcard-editor">
2 <span class="icon is-clickable close-dialog is-large" hx-on="click: wga.closeDialog();">
3 <i class="fas fa-times fa-2x"></i>
4 </span>
5 <h1 class="title">Write a postcard</h1>
6 <div class="columns">
7 <div class="column is-half">
8 <!-- the painting -->
9 </div>
10 <div class="column">
11 <form hx-post="/postcards" hx-target="#d">
12 <input type="hidden" name="image_id" value="{{.ImageId}}">
13 <div class="field">
14 <label class="label">Name</label>
15 <div class="control">
16 <input class="input" type="text" name="sender_name" required autocomplete="name">
17 </div>
18 </div>
19 <div class="field">
20 <label class="label">Message</label>
21 <div class="control">
22 <trix-editor input="message"></trix-editor>
23 <input type="hidden" id="message" name="message">
24 </div>
25 </div>
26 <div class="field is-grouped">
27 <p class="control">
28 <button class="button is-link" type="submit">Send postcard</button>
29 </p>
30 <p class="control">
31 <button type="button" class="button" hx-on="click: wga.closeDialog();">Cancel</button>
32 </p>
33 </div>
34 </form>
35 </div>
36 </div>
37</section>

Both the ✕ in the corner and the Cancel button close the dialog. Inline document.getElementById('d').close() calls get old quickly, so they live in a tiny helper instead:

app.js
JS
1const wga = {
2 els: {},
3};
4
5wga.els.dialog = document.getElementById("d");
6
7window.wga = {
8 openDialog () {
9 wga.els.dialog.showModal();
10 },
11 closeDialog () {
12 wga.els.dialog.close();
13 }
14};

The message field is a Trix editor. It’s a custom element, so it initialises itself when htmx swaps it in, without any extra wiring.

The endpoint

On the Go side (PocketBase, so Echo underneath) the editor is a plain handler that renders a template block rather than a full page:

handlers/postcard.go
GO
1e.Router.GET("postcard/send", func(c echo.Context) error {
2 if !isHtmxRequest(c) {
3 return apis.NewBadRequestError("Unexpected request", nil)
4 }
5
6 awid := c.QueryParam("awid")
7 if awid == "" {
8 return apis.NewBadRequestError("awid is empty", nil)
9 }
10
11 html, err := renderPostcardEditor(awid, app, c)
12 if err != nil {
13 return err
14 }
15
16 return c.HTML(http.StatusOK, html)
17})
handlers/main.go
GO
1func isHtmxRequest(c echo.Context) bool {
2 return c.Request().Header.Get("HX-Request") == "true"
3}

The fragment only makes sense inside the dialog, so anything that isn’t an htmx request is turned away.

Letting the server close it

Submitting the form is a regular hx-post. If storing the postcard fails, the server renders the editor again and it lands back in the dialog. If it works, there’s nothing left to show, and the dialog should simply go away.

The server can’t call close(), but it can send an HX-Trigger header, which htmx turns into an event on the page:

handlers/utils.go
GO
1func sendToastMessage(message string, t string, closeDialog bool, c echo.Context) {
2 payload := struct {
3 Message string `json:"message"`
4 Type string `json:"type"`
5 CloseDialog bool `json:"closeDialog"`
6 }{
7 Message: message,
8 Type: t,
9 CloseDialog: closeDialog,
10 }
11
12 setHxTrigger(c, map[string]any{
13 "notification:toast": payload,
14 })
15}
handlers/postcard.go
GO
1sendToastMessage("Thank you! Your postcard has been queued for sending!", "is-success", true, c)
2
3return nil

On the page, one listener shows the toast and closes the dialog when asked to:

app.js
JS
1document.body.addEventListener("notification:toast", function (evt) {
2 if (evt.detail.closeDialog) {
3 wga.els.dialog.close();
4 }
5
6 bulmaToast.toast({
7 message: evt.detail.message,
8 type: evt.detail.type,
9 })
10})

The server decides whether the dialog stays open, and the same mechanism works for any form that ends up in #d, not just postcards.

Making it look like a modal

The default dialog looks rather plain, but it’s a regular element and takes CSS like anything else. To animate both opening and closing, the dialog stays display: block and is hidden with opacity instead:

_dialog.scss
SCSS
1dialog {
2 display: block;
3 animation: scale-down 0.5s cubic-bezier(0.5, -0.5, 0.1, 1.5) forwards;
4 border-radius: calc(1rem / 3);
5 padding: 1.5rem;
6 box-shadow: 0 0 2rem 0 rgba(0, 0, 0, 0.5);
7
8 &[open] {
9 animation: slide-in-up 0.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
10 }
11
12 &:not([open]) {
13 pointer-events: none;
14 opacity: 0;
15 }
16
17 &::backdrop {
18 backdrop-filter: blur(0.25rem);
19 }
20
21 .close-dialog {
22 position: absolute;
23 right: 1rem;
24 }
25}
26
27@keyframes slide-in-up {
28 0% { transform: translateY(100%); }
29}
30
31@keyframes scale-down {
32 to { transform: scale(0.75); }
33}

One thing to keep in mind with this trick: a closed dialog that is still display: block keeps its content in the accessibility tree, so screen readers can still reach the last form until something replaces it.

Wrapping up

That’s the whole modal: one empty <dialog> in the layout, a server that returns fragments, and a few lines of JavaScript to open, close and listen for HX-Trigger. No modal library, and the browser takes care of the backdrop, the focus trap and Esc.

There are a couple of rough edges I still want to sort out. The dialog opens on click, before the content arrives, so there’s a moment of an empty box. Closing doesn’t clear the old content, and the dialog has no accessible name yet. But for something that’s barely a week old, it already does the job.

TAGS
htmx
HTML
Go
06 · CONTACT

Have a system that needs building?

GET IN TOUCH → PROJECTS →
PRODUCT DATA SHEET
MG—01
BLACKFYRE
S/N MG-1985-1027
Miklós Galicz — Golang Advocate · Solution Architect
MODEL
MG—01 "Miklós Galicz"
SERIES
1985
ORIGIN
Nagykovácsi, Hungary
FUNCTION
Senior Full Stack Engineer · Solution Architect
CORE LANGUAGES
Go · PHP · JavaScript
SPOKEN
Hungarian · English · German
SERVICE LIFE
~20 years in software, ongoing
POWER SUPPLY
Coffee, 2–4 cups / day
DIMENSIONS
1 × human, standard size
OPERATING TEMP.
Calm under production incidents
CONNECTIVITY
[email protected] · github.com/blackfyre · linkedin.com/in/galiczmiklos
Less, but better. Specifications subject to continuous improvement.
● ● ●
MG—01 · SERIES 1985
№ MG-1985-1027
CERTIFICATE OF OPERATION
Certified Operator
Has located every documented feature of the MG—01 without reading the manual. Probably.
TIME
—
FEATURES
—
DATE
—
SIGNED
Miklós Galicz