Building Reusable Modals with HTMX, templ, and Go
Leveraging HTMX, templ, and Go for Efficient and Reusable Modal Dialogs
I am writing a website using HTMX and Go and I needed a simple and repeatable solution to show different types of modals to the user from the backend; primarily a confirmation modal where the user is required to click another button to confirm the action and an information modal that pops up to tell the user something.
I came up with a simple solution using HTMX response headers and a little bit of JavaScript to open and close a dialog
HTML element.
In this post, I am using the following tech stack:
Go
echo - the framework for handling HTTP routing
templ - for generating HTML templates
A sprinkle of vanilla JS
I won't be boring you with detail on how to use these technologies as they are easy to look up and you probably know these already, but if you have any questions, don't hesitate to ask.
First, let's look at what using this solution looks like in practice:
func (ep *settingsEndpoint) deleteAllQuestionsHandler() echo.HandlerFunc {
return func(c echo.Context) error {
// Determine if this action has been confirmed
// confirmed is true of the param is ?confirmed=true
// confirmed is false if the param is ?confirmed=true or not present
confirmed, _ := strconv.ParseBool(c.Request().URL.Query().Get("confirmed"))
if !confirmed {
// Return a confirmation modal if the user has not confirmed the action
m := modal.NewConfirmModal(modal.ConfirmModalState{
Heading: "Delete all questions",
Text: "Are you sure you want to delete all of your questions? This action cannot be undone.",
ConfirmText: "Delete", // text for the action button
ConfirmMethod: "hx-delete", // The metod to be used for the action button
ConfirmAction: c.Request().URL.Path + "?confirmed=true", // The current URL concatenated with the confirmed query param
})
return m.Render(c)
}
// Perform the deletion action - logic not implemented in this example
err := deleteAllQuestions()
return err
}
}
Here we have an endpoint that deletes all of a user's questions. We want to ensure that when a user clicks the big red delete button, they are first presented with a modal asking if they are happy to perform the destructive action and then perform the action if they click the confirm button. This is all happening in the same endpoint.
We create a confirmation modal to ask the user to confirm they would like to delete all questions before the deletion is done. This works by checking for a ?confirmed
query param on the URL; if the value is false
or the param is not present, the modal is returned, otherwise, if the value is true
the deletion is completed.
The ConfirmModal
has an action button that directs to the same endpoint with the confirmed
query param set to true
. The second time we hit this endpoint, the modal will be ignored and the action will be completed.
How does this work under the hood?
Defining the button to perform the action is simple.
<button hx-delete="/settings/questions">
Delete your questions
</button>
As clicking this button will always result in a confirmation modal being displayed, we don't need to include any hx-target
or hx-swap
attributes; these are defined on the backend instead.
In the endpoint, when we call the return m.Render(c)
function, this sets the response headers to make HTMX do something different. We are setting the values of the HX-Reswap
and HX-Retarget
headers to specify where and how we want the modal to be swapped into the DOM.
So in essence, the flow looks like this:
The user clicks on the
Delete your questions
buttonThe
deleteAllQuestionsHandler
handler func is triggered without theconfirmed
query param set, meaning theconfirmed
boolean variable will befalse
A confirmation modal is instantiated with the relevant information
We tell the modal to render itself:
- The
HX-Reswap
header is set toinnerHTML
and theHX-Retarget
is set to#modal-container
; the element that should contain the modal.
- The
The modal is displayed to the user
The user clicks the
Delete
button on the modalThe
deleteAllQuestionsHandler
handler func is triggered again, but this time with theconfirmed
query param set totrue
The modal section of code is ignored and the deletion is triggered
OK, so let's set this up
First of all, we want somewhere for our modals to be rendered. At the bottom of my <body>
section, which is displayed on every page, I have the following HTML:
<div id="modal-container"></div>
This will be where we will inject our modals using HTMX. This HTML should be placed somewhere on your root layout template that will be present on every page on your site, allowing modals to be injected anywhere.
JavaScript
Next, we will want to write the smallest amount of JavaScript to control the modals when opened and closed. As I am using templ, I can define two simple scripts to be included wherever I like:
// modal.templ
package modal
// showModalScript shows the modal when it is loaded into the DOM.
script showModalScript() {
const modal = document.querySelector("#modal-container #modal");
modal.showModal();
}
// closeModalScript closes the modal when the confirm button is clicked.
script closeModalScript() {
const btn = document.getElementById("modal-confirm-btn");
btn.addEventListener("click", function(e) {
const modal = document.getElementById("confirm-modal");
modal.close();
});
}
The first script finds the modal present in the modal-container
element we just created and calls the showModal
method on it. This method opens the modal and prevents interaction with other elements on the screen.
The second script finds the confirmation button present on all modals and adds an event listener to it, so when clicked, it closes the modal.
If you haven't used the HTML dialog element before, here is some documentation from Mosilla: HTMLDialogElement documentation
Writing a modal package in Go
Now, let's write the Go code that will allow us to present different types of modal:
// modal.go
package modal
import (
"github.com/a-h/templ"
"github.com/labstack/echo/v4"
"github.com/thisisthemurph/hx"
)
type Type int
const (
confirmModalType Type = iota
)
type Renderer interface {
Render(echo.Context) error
}
Here, we have a modal
package that will contain all of our modal code and templates. We create a type to represent all of the different modals we can have and then we create a Renderer
interface that represents something that has a Render
function.
I am essentially using the interface to hide some of the internals of the modal package and give the consumer (me in this case) only the functionality that they care about; rendering the modal to the user.
// modal.go
type modalDetail struct {
Type Type
Heading string
Text string
CancelText string
ConfirmText string
ConfirmMethod string
ConfirmAction string
}
Next, we create a struct to represent the detail that a modal may need. This struct will be used for all types of modal we can represent and later we will ensure it satisfies the Renderer
interface.
// modal.go
func newModalDetail(
modalType Type,
heading string,
text string,
cancelText string,
confirmText string,
confirmMethod HXMethod,
confirmAction string,
) modalDetail {
if cancelText == "" {
cancelText = "Cancel" // Default cancel button text
}
if confirmText == "" {
confirmText = "OK" // Default confirm button text
}
if confirmMethod == "" {
confirmMethod = "hx-post" // Default confirm method
}
return modalDetail{
Type: modalType,
Heading: heading,
Text: text,
CancelText: cancelText,
ConfirmText: confirmText,
ConfirmMethod: confirmMethod,
ConfirmAction: confirmAction,
}
}
We then create a private newModalDetail
function to create an instance of the modalDetaul
struct and include some sensible defaults for the values if they are not provided. Providing defaults means the user doesn't need to set these details if they want a basic modal.
This function will be used by other functions in the modal package such as NewConfirmModal
and NewInfoModal
to return a standardised struct for representing a modal's data.
Creating the modals
Before we create the modal, we want to create a reusable templ component for the modal confirmation button.
// modal.templ
package modal
// modalConfirmButton returns a button with the appropriate hx-attribute.
// The default is hx-post.
templ modalConfirmButton(method, actionURL, text string) {
if (method == "hx-delete") {
<button id="modal-confirm-btn" hx-delete={ action } hx-swap="none">
{ text }
</button>
} else {
<button id="modal-confirm-btn" hx-post={ actionURL } hx-swap="none">
{ text }
</button>
}
}
This button is used to perform the action being requested by the user and needs to know the HTMX method to be used, the URL for the action, and the text to be displayed on the button.
Currently, this only supports hx-delete
and hx-post
but is easily extendable when required. You may also need to extend this to accept different swap attributes, but I haven't needed that at this point.
Next, we want to start creating our confirmation modal:
// confirm.templ
package modal
// Defines the fields for creating a confirmation modal
type ConfirmModalState struct {
Heading string
Text string
CancelText string
ConfirmMethod HXMethod
ConfirmAction string
ConfirmText string
}
// NewConfirmModal creates a new confirmation modal
func NewConfirmModal(s ConfirmModalState) Renderer {
return newModalDetail(
confirmModalType,
s.Heading,
s.Text,
s.CancelText,
s.ConfirmText,
s.ConfirmMethod,
s.ConfirmAction,
)
}
// confirmModal is the template for the modal.
templ confirmModal(m modalDetail) {
<dialog id="modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">{ m.Heading }</h3>
<p class="py-4">{ m.Text }</p>
<div class="modal-action flex items-center justify-end">
<form method="dialog">
<button class="btn">{ m.CancelText }</button>
</form>
@modalConfirmButton(m.ConfirmMethod, m.ConfirmAction, m.ConfirmText)
</div>
</div>
</dialog>
// include the required scripts
@showModalScript()
@closeModalScript()
}
In the above code, we do the following:
Create a
ConfirmModalState
struct to represent the fields required for creating a confirmation modalCreate a
NewConfirmModal
method to create a new modal. Note that this takes in theConfirmModalState
and returns aRenderer
. The method returns an instance ofmodalDetail
, passing the fields as required.Define a template for the modal, this is private as it does not need to be accessed outside of the modal package.
Note that the template takes in the
modalDetail
struct as a parameter and not theConfirmModalState
. This is because theNewConfirmModal
function takes aConfirmModalState
and converts it to amodalDetail
which represents any modal type.Note that we also include the two scripts we created earlier, meaning that the scripts are added to the DOM only when required.
We have an issue. The NewConfirmModal
function is returning a modalDetail
, but this struct does not satisfy the Renderer
interface we defined earlier. Let's fix that back in the modal.go
file.
// modal.go
// Maps the modal types to the appropriate templ function
var modalMap = map[Type]func(modalDetail) templ.Component{
confirmModalType: confirmModal,
}
// Render renders the modal
func (m modalDetail) Render(c echo.Context) error {
componentFunc, ok := modalMap[m.Type]
if !ok {
panic("unknown modal type")
}
modal := componentFunc(m)
c.Response().Header().Set("HX-Reswap", "innerHTML")
c.Response().Header().Set("HX-Retarget", "#modal-container")
return modal.Render(c.Request().Context(), c.Response())
}
First, we have a modalMap
variable, which is a map of all the modal types and their respective templ functions for generating the modal template. We can add more here as we create more modal types, but we only have one for now.
The Render
method takes the echo.Context
as a parameter and returns the final HTML of the modal.
We use the map to determine which modal to render, depending on the
m.Type
value. If the modal cannot be found, the method panics. I think a panic here is fine as a mistake can only be made during development and will be quickly picked up.We execute the componentFunc, passing in the modalDetail, to generate the template.
We set the
HX-Reswap
andHX-Retarget
headers on the echo response object.We return the result of the
modal.Render
function, which generates the HTML. Here themodal
is atempl.Component
object and so themodal.Render
function is a function provided by templ.
And that is it. We now have a reusable modal package that we can use to display modals to our users. Here is an example of how to use the modal package from the top of the post now that we have all the context:
func (ep *settingsEndpoint) deleteAllQuestionsHandler() echo.HandlerFunc {
return func(c echo.Context) error {
confirmed, _ := strconv.ParseBool(c.Request().URL.Query().Get("confirmed"))
if !confirmed {
m := modal.NewConfirmModal(modal.ConfirmModalState{
Heading: "Delete all questions",
Text: "Are you sure you want to delete all of your questions? This action cannot be undone.",
ConfirmText: "Delete",
ConfirmMethod: "hx-delete",
ConfirmAction: c.Request().URL.Path + "?confirmed=true",
})
return m.Render(c)
}
// Do the thing - not fleshed out for the example
err := deleteAllQuestions()
return err
}
}
Adding additional modal types
This is great and all, but we only have a single modal type we can display; the confirmation modal. How can we add additional modals to the package:
In the modal.go
file, add the new type to the modal types:
// modal.go
const (
confirmModalType Type = iota
infoModalType
)
Don't forget to add it to the modalMap too:
// modal.go
var modalMap = map[Type]func(modalDetail) templ.Component{
confirmModalType: confirmModal,
infoModalType: infoModal,
}
Now we create a new templ file to keep things organized. I called this one info.templ
:
// info.templ
package modal
// Define the fields required for the info modal
type InfoModalState struct {
Heading string
Text string
ConfirmText string
}
// Create a new function for creating the modal and return a Render interface
func NewInfoModal(s InfoModalState) Renderer {
return newModalDetail(
infoModalType, // Specify the new modal type
s.Heading,
s.Text,
"", // Some of these params are not needed for this modal type
s.ConfirmText,
"",
"",
)
}
// Create the template for the modal.
// Note that there is no action button on this one.
templ infoModal(m modalDetail) {
<dialog id="modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">{ m.Heading }</h3>
<p class="py-4">{ m.Text }</p>
<div class="modal-action flex items-center justify-end">
<form method="dialog">
<button class="btn btn-neutral">{ m.ConfirmText }</button>
</form>
</div>
</div>
</dialog>
// Don't forget the scripts
@showModalScript()
@closeModalScript()
}
This follows the exact pattern we had for the confirmation modal, but the info modal doesn't need as much detail as it doesn't do anything other than prompt the user with some information. It also doesn't need a confirmation button, just a button that will close the modal.
And that is it, that's how easy it is to create a new modal. It is generally copy and paste with a few changes.
Summary
In this post, we explored a method to create reusable and dynamic modals using HTMX and Go, focusing on confirmation and information modals. By leveraging HTMX response headers and minimal JavaScript, we achieved (IMO) a clean and efficient solution to enhance user interactions. We walked through the implementation details, from setting up the modal container and JavaScript scripts to creating a Go package that renders various modals. This approach not only simplifies modal management but also ensures that modals are consistent and easy to extend. With this setup, you can now confidently add more modal types to your project, improving user experience with minimal effort.