DEV Community

Robodobdob
Robodobdob

Posted on

Native HTML Dialogs with HTMX

There are many ways to implement modal popups in web development these days, but they all expect you to import a library and implement their approach.

I am a big fan of using a native approach to interaction wherever possible. It frees you from dependence of third-parties and gives you a cleaner codebase.

The Modal

Let's start with the modal element - the html dialog element. This will give us largely all the functionality that external libraries give you but with some other nice bits. For instance, pressing ESC will cancel the dialog. You also don't need to worry about z-indexes or some janky DIV that pretends to be a backdrop.

I will paste the code I am using in a current project and you can hopefully infer the necessary bits you need to know.

<dialog id="utilityModal" class="w-100 h-100 p-7" hx-target:inherited="#utilityModal_content">
    <div class="position-absolute top-0 end-0 p-2 d-flex">
        <Working Id="utilityModal_spinner"/>
        <button class="btn btn-close" type="button" command="close" commandfor="utilityModal"></button>
    </div>
    <div id="utilityModal_content" class="p-1 h-100"></div>
</dialog>
Enter fullscreen mode Exit fullscreen mode

Looking at this code we can point out a few important bits.

The dialog element is the wrapper element. I also add an hx-target at to the root with the new HTMX4 :inherited modifier. This means all interactions inside the modal will be contained in the modal (unless otherwise specified).

The <button... > is used as a close button for the dialog. You will note I am also using the freshly-minted native command and commandfor APIs. These allow you to issue a set of commands to other elements without any additional JavaScript.

The utilityModal_content div is perhaps the most important part. It is an empty container which will hold the content of the modal when it is opened.

The Trigger

Now we have a modal defined, we need to launch it and put something useful into it. We can do this simply using HTMX syntax we know and love:

<button type="button" class="btn btn-light p-1" command="show-modal" commandfor="utilityModal" hx-get="/editnote" hx-target="#utilityModal_content">
    <Icon Name="plus" />
</button>
Enter fullscreen mode Exit fullscreen mode

Even if you're not an HTMX dev, you should be able to work out what is going to happen here. Clicking the button will first show the modal using the native command/commandfor API. Next, HTMX will kick in and do an hx-get to the endpoint and put the response into the hx-target element. And as we know from above, that target is the content of the modal.

The end result is a DOM like this:

Before clicking:
The dialog element before clicking

After clicking:

NOTE: the top-layer and ::backdrop pseudo-elements are added by the browser and provide hooks for the styling shown below

Modal Utilities

Now we have a functioning, native modal with content being injected from HTMX, we could just leave it there. But you incur a few oddities that you probably want to deal with.

Issue 1

Firstly, because you are injecting markup into the DOM as your hx-target, it will remain in the DOM until you remove it, refresh the browser, or replace it with some other content.

If you leave the markup in the DOM, you can get a weird user experience where the modal opens again and it will immediately display the content from the previous open while it waits for updated content. This can be jarring to the user and even confusing. They might think something has gone wrong.

To counter this, I add a small modal utility .js file which handles some clean up on the modal content when it is closed. Yes, it's not native, but sometimes you just have to use JavaScript :-)

var dialog = document.getElementById('utilityModal');
if (dialog) {
    dialog.addEventListener('close', (e) => {
        document.getElementById('utilityModal_content').innerHTML = '';
    });
}
Enter fullscreen mode Exit fullscreen mode

Again, this should be pretty self-explanatory. We register an event listed on the DOM and listen for the close event which will come from the dialog element closing (either by button click or pressing ESC). The event handler itself simply wipes the content of the utilityModal_content element so it is clean the next time it opens.

Issue 2

Now that we have the content being scrubbed on close, the second issue is, how can we close the modal programmatically in response to an event?

This is actually stunningly simple and we only need a few lines of code and HTMX will do the rest.

To start, we need our endpoint to return he right things to HTMX so it can close our modal.

app.MapDelete("/deletenote/{noteId:guid}", 
            async (Guid noteId, INotesService notesService, HttpContext httpContext) =>
                {
                    var note = await notesService.GetNoteByIdAsync(noteId);
                    await notesService.RemoveNoteAsync(note);
                    httpContext.Response.Headers.Append("HX-Trigger", "notes-updated, close-modal");
                    return Results.Ok();
                });
Enter fullscreen mode Exit fullscreen mode

I am a .Net developer, so this is a C# minimal API endpoint definition, but if you have done any API development in recent years, the syntax again, should be familiar enough.

In the code snippet, I return two HX-Trigger headers. These headers will be intercepted by HTMX on the response and actioned accordingly.

All, we need to do now is wire up another listener in our utilities JavaScript and we can close (and scrub) the modal.

var dialog = document.getElementById('utilityModal');
if (dialog) {
    dialog.addEventListener('close', (e) => {
        document.getElementById('utilityModal_content').innerHTML = '';
    });

    document.body.addEventListener("close-modal", function(evt){
        dialog.close();
    })
}
Enter fullscreen mode Exit fullscreen mode

Summary

That's it! You should now have the bones of a HTML modal using the native dialog element and a couple of simple event listeners. And HTMX, of course.

Bonus Styling

Because styling is not everyone's forte, here is the CSS I have used on a few projects now for this modal.

dialog {
    display: flex;
    opacity: 0;
    pointer-events: none;
    background-color: aliceblue;

    &[open] {
        display: flex;
        opacity: 1;
        pointer-events: inherit;
        flex-direction: column;
        border: none !important;
        border-radius: 12px;
        box-shadow: 0 0 rgba(0, 0, 0, 0), 0 0 rgba(0, 0, 0, 0), 0 25px 50px -12px rgba(0, 0, 0, .25);
    }

    &::backdrop {
        background: rgba(0, 0, 0, .25);
        backdrop-filter: blur(3px)
    }
}

#utilityModal_content {
    overflow-y: auto;
    overflow-x: hidden
}
Enter fullscreen mode Exit fullscreen mode

Tweak it as you like, but this particular snippet will render like this:

Screenshot of my note-taking app showing the modal dialog styling

Top comments (0)