Summary
Since the last article, the product works, and often that’s all we need. But if you want to make it feel nice, you can use animations and client side scripting for a better UX.
You will never get to SPA levels, but probably above what you expect.
HTMX adds and removes its CSS own classes during a request, so we will target .htmx-swapping
as a way to trigger a deletion animation.the last article,
Then we will save a few requests by calculating the task count on the client side, the old fashion way: by using the DOM as our data store, and events to tell us when to do it.
First make it work
Writing an HTMX application means writing an hypermedia application, which does imply the client has fewer responsibilities. It doesn't mean the client cannot take any responsibility, however.
As much as it's nice to be able to setup complex applications with little client-side code, modern software users expect modern interactivity. They are used to animations, transitions and fast feedback.
While you won't be able to provide the same features as an advance SPA would (like offline mode or live synchronization), you can use still use CSS and JS to manipulate the DOM, react to events, provide error handling, and so on.
Beyond the joke that went for a while on Twitter about HTMX not being able to do drop down, there is simply the reality that advanced widgets will require a lot of dynamism. For that you need scripting.
I'm not going to sell you hyperscript, HTMX's weird but interesting cousin. In this final article of our HTMX series, we will instead demonstrate that all the things you learned about frontend technology is not wasted with HTMX, it will make you better at using it.
Then make it beautiful
If you are an experienced front end person, I don't need to convince you you can do a lot with pure CSS. But a dynamic UI needs something to anchor your animations to.
HTMX solves this problem with two tools:
Adding and removing classes during the lifecycle of any request. E.G: hx-indicator lets you add spinners when the request is in flight.
Firing events during the same lifecycle. E.G: htmx:progress will notify you of the level completion of a request.
Let's use the first system to add a bit of bling to our task deletion. Like giving the impression our task is falling out of the screen:
First, we need to setup a CSS animation in our index.html
script tag. This is not an HTMX thing, it's just regular cascading goodness:
@keyframes removed-item-animation {
0% {
opacity: 1;
transform: rotateZ(0);
}
20% {
opacity: 1;
transform: rotateZ(140deg);
}
40% {
opacity: 1;
transform: rotateZ(60deg);
}
60% {
opacity: 1;
transform: rotateZ(110deg);
}
70% {
opacity: 1;
transform: rotateZ(90deg) translateX(0);
}
90% {
opacity: 1;
transform: rotateZ(90deg) translateX(600px);
}
100% {
opacity: 0;
transform: rotateZ(90deg) translateX(600px);
}
}
This will give the impression that the element is detaching on one side, like a screw is loose, and it's rotating on the one remaining. Now we want to hook it to the htmx-swapping
class, which HTMX automatically adds to an element when it's swapping it.
.htmx-swapping {
animation: removed-item-animation 2s cubic-bezier(.55,-0.04,.91,.94) forwards;
transform-origin: 0% 100%;
}
Again, it's regular CSS, nothing HTMX here, apart from the class name. We make it perform the detaching animation, then fall off the screen, over 2 seconds.
Our index file is now:
<!DOCTYPE html>
<html>
<head>
<title>Todo List</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<style>
li {
list-style-type: none !important;
}
input:checked + label {
text-decoration: line-through;
}
.htmx-swapping {
animation: removed-item-animation 2s cubic-bezier(.55,-0.04,.91,.94) forwards;
transform-origin: 0% 100%;
}
@keyframes removed-item-animation {
0% {
opacity: 1;
transform: rotateZ(0);
}
20% {
opacity: 1;
transform: rotateZ(140deg);
}
40% {
opacity: 1;
transform: rotateZ(60deg);
}
60% {
opacity: 1;
transform: rotateZ(110deg);
}
70% {
opacity: 1;
transform: rotateZ(90deg) translateX(0);
}
90% {
opacity: 1;
transform: rotateZ(90deg) translateX(600px);
}
100% {
opacity: 0;
transform: rotateZ(90deg) translateX(600px);
}
}
</style>
</head>
...
</html>
Then let's move to _task.tpl
and add a little delay to out task deletion. We used to have:
hx-swap="delete"
We now have:
hx-swap="delete swap:2s"
This uses a modifier to tell HTMX to perform the replacement between the current element and the response a bit slower, to give time to our animation to execute. Since we are also using delete
, we are technically replacing the initial element with nothing. But the mechanism is the same either way.
The full task template looks like this:
<li>
<input type="checkbox"
name="task"
id="task{{task['id']}}"
% if task['done']:
checked
% end
hx-trigger="change"
hx-put="/tasks/{{task['id']}}"
hx-swap="outerHTML"
hx-target="closest li"
autocomplete="off"
/>
<label for="task{{task['id']}}">
{{task['title']}}
</label>
<a
hx-delete="/tasks/{{task['id']}}"
hx-swap="delete swap:2s"
hx-target="closest li"
>X</a>
</li>
There is nothing to add. The rest is automatic. When the user clicks the X link, HTMX triggers the deletion. During the swap, which now takes 2 seconds more, HTMX adds the class htmx-swapping
to the target. Here the target is our closest li
, so the li
containing our task will suddenly be the target of our animation, it will rotate then fall of the screen, then be delete for good from the DOM by HTMX.
And finally make it fast
There are some calculations that don't need to happen on the server. If you want to save a bit of CPU and bandwidth, those are low-hanging fruits.
Usual premature optimization quotes from Tony Hoare and Donald Knuth apply, obviously.
For our to-do list, the current state of affairs is fine, performance-wise. For the purpose of our tutorial, though, let's assume we measured counting all those tasks every time we update the list is having a huge impact on our database since we reached 10 million concurrent users.
The first thing you do is popping the champagne. Congrats, you have a good problem to have. Not many people get to create a system that is that popular.
The second thing is to move the updating of the task counter to the client.
First, we can write a little function that will calculate exactly that by counting the dom node.
function calculateTaskCount() {
const checkboxes = document.querySelectorAll('input[type="checkbox"][name="task"]');
const checkedCheckboxes = Array.from(checkboxes).filter(el => el.checked);
return `(${checkboxes.length}/${checkedCheckboxes.length})`
}
Second, we need to map it to something happening. Since last article, every time we update the counter, we trigger an update-counter
event.
We can hook on that, and update our counter every time it happens. This can be done with regular JS given HTMX emits normal DOM events:
document.addEventListener('DOMContentLoaded', () => {
document.body.addEventListener('update-counter', () => {
document.getElementById('taskCount').innerHTML = calculateTaskCount()
})
})
But this is quite verbose, so HTMX provides a shortcut for that:
htmx.on('update-counter', () => {
document.getElementById('taskCount').innerHTML = calculateTaskCount()
})
This does assumes that our task count <span>
is accessible through a #taskCount
id. We also have do disable the old behavior of making a request. So we go from our old title:
<h1>Todo <span
hx-trigger="update-counter from:body"
hx-get="/tasks/count"
>({{counts['done']}}/{{counts['total']}})</span></h1>
To:
<h1>Todo <span id="taskCount">({{counts['done']}}/{{counts['total']}})</span></h1>
The whole index looks now like this (colored version):
<!DOCTYPE html>
<html>
<head>
<title>Todo List</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<script>
function calculateTaskCount() {
const checkboxes = document.querySelectorAll('input[type="checkbox"][name="task"]');
const checkedCheckboxes = Array.from(checkboxes).filter(el => el.checked);
return `(${checkedCheckboxes.length}/${checkboxes.length})`
}
document.addEventListener('DOMContentLoaded', () => {
document.body.addEventListener('update-counter', () => {
document.getElementById('taskCount').innerHTML = calculateTaskCount()
})
})
</script>
<style>
li {
list-style-type: none !important;
}
input:checked + label {
text-decoration: line-through;
}
.htmx-swapping {
animation: removed-item-animation 2s cubic-bezier(.55,-0.04,.91,.94) forwards;
transform-origin: 0% 100%;
}
@keyframes removed-item-animation {
0% {
opacity: 1;
transform: rotateZ(0);
}
20% {
opacity: 1;
transform: rotateZ(140deg);
}
40% {
opacity: 1;
transform: rotateZ(60deg);
}
60% {
opacity: 1;
transform: rotateZ(110deg);
}
70% {
opacity: 1;
transform: rotateZ(90deg) translateX(0);
}
90% {
opacity: 1;
transform: rotateZ(90deg) translateX(600px);
}
100% {
opacity: 0;
transform: rotateZ(90deg) translateX(600px);
}
}
</style>
</head>
<body>
<div class="container">
<br>
<h1>Todo <span id="taskCount">({{counts['done']}}/{{counts['total']}})</span></h1>
...
</html>
You don't have to inline the <script>
or <style>
tags, but it makes things easy for me in this case.
Despite all that, it will not work as expected. This is because the update-counter
event is fired before HTMX swaps the nodes. We need to tell it to fire it later, once all the new tasks are settling in their cozy list.
This is what you want if the event is used to send a request: the sooner the better, and the servers is up to date anyway. But in our case, the count would be wrong.
We can fix this by modifying the header we send, from server.py. Those calls:
response.set_header("HX-Trigger", "update-counter")
Become:
response.set_header("HX-Trigger-After-Settle", "update-counter")
And now it works (see full server.py file). After every update is finished, we refresh the task counter without making a server request.
You can try it by cloning the animations-and-scripting tag, or download the zip and run server.py
from inside the directory "10_animations".
Of course, we have made a trade off, now the counter is using the HTML as the source of data instead of the server. If you have been using JSON for that, it will feel less clean. If you have been using jQuery plugins, it will give you some flashbacks, even.
The balance between perf, ability to compose and ease of maintenance is delicate, and you will have to decide at every step what is worth what. It's true for every project, and every stack.
I found my threshold for this particular trade-off higher than I was expecting: you can go very far with this and still enjoy a fluid development experience.
But eventually there is a ceiling that will scream "use a damn SPA framework for this you dummy".
Finding where it stits is an exercise left to the reader.
That's what my clients pay me for, after all.
What to learn from now?
HTMX’s API is not huge, so you pretty much have all what you need with this series.
If you want to dive a bit deeper, you can checkout Out Of Band swapping and websocket support. Also debugging tips are useful.
Finally, for convenience, there are a bunch of HTMX extensions that may make you life easier. Things like better loading states, multiple swaps ability or easier class application.
And you can, of course, write your own.
This blog being a Python shop, we will not go further into the realm of frontend dev. I have a Django vs Flask article to write.
Love it!
Thanks for writing this series. Do you know of any good tutorials or blogs on CSS, specifically animations? It's totally new to me as a backend dev.