HTMX notifications

This is a short article about how to implement notification system with HTMX and zero other dependencies. We will use Hx-Trigger to trigger an event which will add a notification element on the screen and remove it after the specified time .

The following are the requirements for the notification system:

Let's start by reacting to a event sent from the server. We will use HTMX ability to trigger an event on the client. This is achieved by adding Hx-Trigger header to a response for a request triggered by HTMX. Following the HTMX docs our response will have the following header

Hx-Trigger: notification-1

On the client side we will register a listener on the document for this event, and show an alert when the listener is triggered.

document.addEventListener("notification-1", (event) => {
  alert("Hello world!");
});

You can test this by . As you can see this triggers an alert dialog. If you open the network tab and look at the headers of the response you will indeed see that we including the header which triggers this action.

We now have the ability to trigger code execution on the client from the server, great! But we need to add additional data in the header to include a message. Luckily besides raw event names, HTMX allows us to add a JSON with the event which will be added in event.detail.

So our Header will look like this

Hx-Trigger: {"notification-2": { "message": "Hello world!"}}

And our client listener

document.addEventListener("notification-2", (event) => {
  alert(event.detail.message);
});

With this approach we can pass arbitrary data from server and use it on the client. HTTP headers can have only US-ASCII characters. If you want to add non US-ASCII characters you need to escape them on the server and unescape them on the client.

Let's move onto actually displaying the notification. We will add an empty div with absolute position to the body so we can insert notifications inside it.

<div id="notifications-container" style="position: fixed; bottom: 10px; right: 10px"></div>

and instead of showing an alert create a new div element which will be inserted inside the notifications-container.

document.addEventListener("notification-3", (event) => {
    const container = document.getElementById("notifications-container");
    const element = document.createElement("div");

    const title = document.createElement("p");
    title.innerHTML = event.detail.title;

    const description = document.createElement("small");
    description.innerHTML = event.detail.description;

    element.appendChild(title);
    element.appendChild(description);
    container.appendChild(element);
});

Let's . Perfect, let's the notifications and add a little styling and try .

CSS styles
.notifications-container {
    position: fixed;
    bottom: 10px;
    right: 10px;

    display: flex;
    flex-flow: column;
    gap: 10px;
    max-width: 24rem;
    width: fit-content;
    align-items: flex-end;
}
.notification {
    box-shadow: 10px 5px 5px black;
    padding: .75rem;
    background-color: #142e05;
    border: 2px solid #16a34a;
    border-radius: .5rem;
    overflow: hidden;
    min-width: 18rem;
    width: fit-content;
    position: relative; 
    p {
        font-weight: 600;
        margin-bottom: 10px;
        color: #cdf89e;
    }
    small {
        font-weight: 300;
        color: #cdf89e;
    }
}

The only thing left is to dismiss the notification after specified time has elapsed. We will use setTimeout and an addition attribute on the detail object to get the time in milliseconds.

Our final header will look like this:

Hx-Trigger: {"notification-5": { "message": string, "description": string, "duration": number }}

And we add setTimeout at the end of our listener.

setTimeout(() => {
    element.remove();
}, event.detail.duration);

It , but needs a little bit of animations in my opinion.

Here is the final code with additional styling, animations and a little bit of refactoring.
.notifications-container {
    position: fixed;
    bottom: 10px;
    right: 10px;

    display: flex;
    flex-flow: column;
    gap: 10px;
    max-width: 24rem;
    width: fit-content;
    align-items: flex-end;
}
.notification {
    position: relative; 
    overflow: hidden;

    width: fit-content;
    min-width: 18rem;
    padding: .75rem;

    border: 2px solid #16a34a;
    border-radius: .5rem;
    box-shadow: 10px 5px 5px black;
    background-color: #142e05;

    transition: opacity 0.3s linear,
                transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
    p {
        font-weight: 600;
        margin-bottom: 10px;
        color: #cdf89e;
    }
    small {
        font-weight: 300;
        color: #cdf89e;
    }

    &.hidden-enter {
        transform: translateY(100%);
    }
    &.hidden-exit {
        transform: translateX(100%);
    }

    :last-child {
        position: absolute;
        bottom: 0;
        left: 0;

        width: 100%;
        height: 10px;

        background-color: #16a34a;
    }
}
function showNotification(titleText, descriptionText, duration) {
    const container = document.getElementById("notifications-container");
    const notification = document.createElement("div");
    notification.classList.add("notification", "hidden-enter");

    const title = document.createElement("p");
    title.innerHTML = titleText;

    const description = document.createElement("small");
    description.innerHTML = descriptionText;

    const progressBar = document.createElement("div");
    progressBar.classList.add("notification-progress");
    progressBar.style.setProperty("width", "0%");
    progressBar.style.setProperty("-webkit-transition", `width ${duration}ms linear`);
    progressBar.style.setProperty("-moz-transition", `width ${duration}ms linear`);
    progressBar.style.setProperty("-o-transition", `width ${duration}ms linear`);
    progressBar.style.setProperty("transition", `width ${duration}ms linear`);

    notification.appendChild(title);
    notification.appendChild(description);
    notification.appendChild(progressBar);

    container.appendChild(notification);

    setTimeout(() => {
        notification.style.opacity = 100;
        notification.classList.remove("hidden-enter");
        progressBar.style.setProperty("width", "100%");
    }, 10);

    setTimeout(() => {
        notification.style.opacity = 0;
        notification.classList.add("hidden-exit");
        setTimeout(() => {
            notification.remove();
        }, 300);
    }, duration + 100);
}
document.addEventListener("notification-6", (event) => {
    console.log(event.detail);
    showNotification(event.detail.title, event.detail.description, event.detail.duration);
});
And , I hope you got something out of this. If you have any comments/suggestions let me know.

Updates: