CONTENTS06 +
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
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">Send5 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-indexwars; - the rest of the page becomes inert, so clicks and tabbing stay inside the modal;
- Esc closes it;
- the
::backdroppseudo-element is there to style.
The content
The server answers with a fragment, which htmx drops into the dialog:
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:
1const wga = {2 els: {},3};45wga.els.dialog = document.getElementById("d");67window.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:
1e.Router.GET("postcard/send", func(c echo.Context) error {2 if !isHtmxRequest(c) {3 return apis.NewBadRequestError("Unexpected request", nil)4 }56 awid := c.QueryParam("awid")7 if awid == "" {8 return apis.NewBadRequestError("awid is empty", nil)9 }1011 html, err := renderPostcardEditor(awid, app, c)12 if err != nil {13 return err14 }1516 return c.HTML(http.StatusOK, html)17})
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:
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 }1112 setHxTrigger(c, map[string]any{13 "notification:toast": payload,14 })15}
1sendToastMessage("Thank you! Your postcard has been queued for sending!", "is-success", true, c)23return nil
On the page, one listener shows the toast and closes the dialog when asked to:
1document.body.addEventListener("notification:toast", function (evt) {2 if (evt.detail.closeDialog) {3 wga.els.dialog.close();4 }56 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:
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);78 &[open] {9 animation: slide-in-up 0.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;10 }1112 &:not([open]) {13 pointer-events: none;14 opacity: 0;15 }1617 &::backdrop {18 backdrop-filter: blur(0.25rem);19 }2021 .close-dialog {22 position: absolute;23 right: 1rem;24 }25}2627@keyframes slide-in-up {28 0% { transform: translateY(100%); }29}3031@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.