Making a Trello clone using htmx

Is it possible to create a Trello clone using htmx? Yes, and I’ll show you how.

Making a Trello clone using htmx

In my last article, I looked at front-end technologies and sang the praises of htmx, which I declared is a simpler way of implementing dynamic web applications. Simpler than the prevailing choice of today, which usually means some form of React or Vue, and which forces you to use JavaScript and have complicated build processes in order to get something working.

Predictably, I got some reactions from front-end developers. Some of those were positive or ‘positive but’. Many of the rest were along a few broad lines of thought, such as:

  • htmx is only suitable for ‘small’ applications, and you’ll need a SPA framework when it becomes too large;
  • I’ll still need a JSON API for my mobile apps, and I don’t want to do the same work twice;
  • SPA frameworks are bad, but Next/Nuxt/Sveltekit are basically htmx, but magic;
  • Your examples suck and a real developer would do this differently in React;
  • Actually, htmx is just another framework;
  • This doesn’t look any simpler;
  • People promoting <not my solution> should learn what they’re talking about before they write something;
  • It would be so hard/impossible to build X using htmx.

Some of these have already been addressed by the essays on the htmx site. Let’s focus on the last one. And let’s not talk about building X, the website formerly known as Twitter. Elon Musk is such a dick for associating the universal placeholder with that cesspool of hatred and censorship. Anyway, moving on. Let’s try and build a Trello clone using htmx. Why Trello? Well, because someone specifically called me out on it. ‘Try making a clone of trellix, an app built with remix’. To which I said ‘that doesn’t look too difficult’. So let’s do it.

Step 1: What are we building?

Its name implies that Trellix is a Trello clone. For those of you don’t know Trello: Trello is a work organizer, basically a very fancy ‘to do’ app, that lets you put cards, representing work, into stacks, which usually represent statuses, like ‘to do’, ‘doing’, ‘blocked’, and ‘done’. These stacks are represented as vertical ‘lanes’, and you can drag and drop cards between lanes or to a different position in the same lane. All of this is done on a ‘board’, which you can think of like a project. Boards are the ‘root’ of the user interface; except for managing boards themselves, everything is done in the context of a board.

A simple board created using Trellix

Trellix in all its blendered - sorry - Remixed glory

What we’ll be creating is the following behaviors:

  • Creating a new board;
  • Renaming a board;
  • Creating lanes;
  • Creating cards;
  • Reordering cards in a lane;
  • Moving cards between lanes;
  • Removing cards.

What about, for example, renaming lanes? Or editing cards? First of all, some of those are not implemented by Trellix, either, but second of all, the point of this is not to create an entire application and solve all of the potentially hard problems, but to demonstrate how simple it can be to implement something that looks very complicated. I’ll also skip the authentication and sign-up part, to keep the scope small.

Step 2: How are we building it?

Obviously, we’re using htmx, but what else? Because htmx is such a back-end-agnostic solution, I could have used pretty much anything. I chose something I’m intimately familiar with, to avoid the entire article being discredited because some troll goes ‘lol, that not how you do that in go’. ASP.NET Core MVC it is. I chose MVC over Razor Pages, because MVC gives you a little bit more control, and lets you, for example, easily return a different view depending on the outcome of an operation.

We’ll use Bootstrap 5 as a CSS ‘framework’, because I’m familiar with it, it’s succinct, and it provides a good starting point.

Step 3: Let’s build!

I’ll skip over all of the plumbing required to make it work in .NET, but feel free to check out the source code. Remember that the context of this application is as a htmx proof-of-concept, not as an example of how to build a .NET application. Corners have been cut, and there are undoubtably many edge cases that are not handled.1

For the most part, discussing which what CSS features or Bootstrap classes were used is outside of the scope of this article. Where it’s really used to fix a problem, I will discuss it. I’ll also omit most of the classes and such from the code in this post, if they’re only used for styling purposes.

Creating boards

Our index page will be simple. There will be a form to create new boards, and a list of existing boards which you can navigate to.

Our first interesting thing is to let users create boards, so they actually have a canvas to work on. This will just be a form that will POST to an endpoint, which will create the board and redirect the user to the newly created board. There’s no real advantage in doing a partial update here, so we won’t. The redirect is the old PRG (POST-redirect-GET) pattern in action. Just because we can, we’ll make the form a ‘boosted’ form. This means the form will be a regular old HTML form, but when JavaScript is enabled, htmx will turn it into an asynchronous fetch request.

This is what the form looks like:

<form method="post"
      action="/boards"
      hx-boost="true">
    <div>
        <label for="newBoardName">Name</label>
        <input type="text" 
               required 
               name="Board.BoardName"
               id="newBoardName"
               value="@Model.Board.BoardName" 
               autocomplete="off" />
    </div>
    <div>
        <label for="newBoardColor">Color</label>
        <div>
            <input type="color" 
                   id="newBoardColor"
                   name="Board.Color"
                   value="@Model.Board.Color" />
        </div>
        <button type="submit">Create</button>
    </div>
</form>

If you’re not familiar, the @ syntax is used in Razor to indicate a C# expression.

Handling the request is simple enough: create the board, then redirect to it. There’s a problem, though: pressing the back button doesn’t work as you’d expect.

When the request is boosted, using htmx, here’s what happens: htmx will send a POST, which will result in a response with a 302 status (a redirect). Htmx will follow the redirect, request the indicated page and render the response just fine. A boosted request tries to behave like a ‘normal’ request in many ways, including history support, or in other words: you can use the back button to navigate to the previous state. To do this, htmx takes a snapshot of the page’s DOM just before swapping in the respone, and caches it in local storage. When you press the back button, htmx simply swaps in the cached response; there’s no request to refresh any of the data. This makes sense if you consider that whatever state the page was in could have been the result of an untold number of other interactions.

In contrast, when the form is submitted without htmx, the POST request results in a 302 status (a redirect), which prompts a second request. Because the response to the first is a redirect, the first response is not added to the browser’s history. When you click the back arrow on the newly created board page, the browser will just issue a new request to the index page, which will display the board you just added.

There is a number of ways we can handle this. We can turn the redirect to the board page into a full page load with the HX-Redirect header. This works, but it’s essentially defeating the point of having a boosted request.

We can prevent htmx from caching the index page, by using hx-history="false". This also works, but it can be a heavy-handed solution, since the entire page is requested when you navigate back to it.

We can also use the load trigger to request only the list of boards whenever the page loads. This might be the best solution if you want to avoid loading the entire page because it’s slow to load, for example. By default, htmx has no separate event for ‘a page was restored from the history cache’ (there’s an extension that adds it, though). This means that each time you navigate to the page, whether it’s ‘normally’ or backwards in the browser history, the load event will always be triggered. In other words: you click your way to the index page, which comes with a fresh list of boards, and then as soon as the page is done loading, a request is triggered to get a fresher list of boards. A way to deal with this somewhat gracefully would be to provide the request with some version information that lets the endpoint respond with a 204 (No Content) response in case there is nothing more recent. Still, that is an additional request.

Since our index page is pretty light, let’s use the simplest solution and set hx-history="false" on the list of boards.

Renaming a board

The first thing on our board page is the board title, which you should be able to change. Well, the first thing is really the background color of the board. The easiest way to implement this would be to set a style="background-color: <something>" on the body of the page, since that covers the entire page from top to bottom. Unfortunately, htmx does not touch the body element when doing partial page loads. Fortunately, we live in a day and age where it is trivial to make a lay-out that covers the entire page.

Our page set-up is like most Bootstrap pages: we have a navigation bar at the top, and a ‘container’ below it, which contains the page content. We set the body to display: flex to enable the ease of use of CSS Flexbox, and then we set min-height: 100vh (the vh unit represents 1% of the viewport’s height) to force it to cover all of the browser window. Then we set flex-grow: 1 on our container element and we’re set! Modern CSS is just amazing.

Back to renaming a board. In Trellix, the process is made very sleek; when you click the board name, it’s replaced by a borderless text field with the name, and you can immediately start typing and press Enter to commit your changes, or tab away or click outside the field to dismiss your changes. Let’s see if we can do something similar.

The first thing to solve is to show an input field when we click the title. That’s easy enough; we just expose an endpoint that returns the input field, plus whatever other markup is required, and then we wire up the board title to request it when clicked. Since ‘click’ is the default trigger for anything that’s not an input or form, this is very concise:

<h1 hx-get="/boards/@Model.BoardId/editName"
    hx-swap="outerHTML">
  @Model.Name
</h1>

The form to do the rename itself, which would be returned by the editName endpoint, is also not that special. I’m using a form here because we don’t want the request to be triggered on every key press, but instead only when the user presses Enter. We could use hx-trigger for this, but using a form is easier, cleaner, and it might eventually be necessary if you want to include CSRF tokens.

<form hx-put="/boards/@Model.BoardId/editName" 
      hx-swap="outerHTML">
  <input type="text" 
         name="boardName" 
         value="@Model.Name" 
         required 
         placeholder="Enter board name" 
         autocomplete="off"
         autofocus />
</form>

Note the autofocus attribute on the text field. It has been supported by browsers since 2011 to specify that a particular input should receive focus when the page is loaded. Htmx supports this attribute as well, so you can use it to move the focus after loading in new content, instead of having to manually write calls to focus().

The response from the endpoint handling the form should be an updated h1 tag, which can then be replaced by a form when it’s clicked, and so on.

Not renaming a board

Letting users rename a board is not that difficult, but what if they change their minds, or if they clicked the title by accident? Canceling the editing process should be as easy as clicking somewhere outside the input field, or pressing Tab to focus on something else.2 At first glance, this looks like something you definitely need JavaScript for, but we actually don’t. Combining JavaScript events with the various htmx attribute will do the job.

When you press Tab, the focus moves to the next element, firing an event on the original element which JavaScript calls blur. Get it? Because it’s the opposite of focus? JavaScript has some weird event names. Anyway, the blur event is fired if, for whatever reason, the input loses focus, and that includes clicking outside it.

<div hx-get="/boards/@Model.BoardId/name"
     hx-trigger="blur from:#boardName"
     hx-swap="outerHTML">
  <form hx-put="/boards/@Model.BoardId/editName" 
        hx-swap="outerHTML">
      <input type="text"
             id="boardName"
             name="boardName"
             value="@Model.Name"
             required
             placeholder="Enter board name"
             autocomplete="off"
             autofocus />
  </form>
</div>

The trigger blur from:#boardName indicates the request should be triggered whenever a blur event is fired by the element with ID boardName. Alternatively, we could have made the text field itself make the request and replace the form, but it seemed neater to have a container element for this purpose. To be honest, that is how I originally implemented it, but while editing this article, I changed my mind. It happens; you write some code and you’re happy with it, then a month later you hate it.

This completes the implementation of the ‘click-to-edit’ pattern seen on the htmx site. Click an element to replace it with an editable version and a ‘cancel’ button, which restores it to the static version, as does clicking the optional ‘save’ button or pressing Enter.

Changing the page title

Since a board is the primary focus of the board page, you would expect it to reflect the board’s name in the page title. It would be nice if the title would update when we change its name. With htmx, this is as easy as including a <title> tag in the response.

In response to changing the title, we’ll just add it in the response, next to the new board title.

@if ((bool) (ViewData["RenderTitleTag"] ?? false))
{
  <title>@Model.Name - Htmello</title>
}
<h1 hx-get="/boards/@Model.BoardId/editName"
    hx-swap="outerHTML">
  @Model.Name
</h1>

ViewData is ASP.NET’s ad hoc view data dictionary, which is a convenient (if a bit ugly) way to pass optional data from controllers to views. This is the same partial view used to render the board name as part of a full page request, so the title tag is rendered conditionally.

And yes, ‘Htmello’ is the name I came up with for my htmx-based Trello clone.

Creating lanes

Now that we have a title, we need the ‘lanes’ that hold cards. The interactions around creating lanes are a little bit more complicated.

  • The ‘add lane’ button always needs to be to the right of the last lane;
  • Clicking the ‘add lane’ button needs to reveal a text field and move focus to it, and moving focus away from that needs to hide the text field and show the ‘add lane’ button again;
  • Pressing Enter and finishing the interaction needs to result in a lane being added to the right of the last lane, and the ‘add lane’ button re-appearing next to the new lane;
  • Just to make things a bit more interesting: lane names need to be unique, so a submission can also be rejected with a validation error.

Could this have been solved purely using htmx? Probably. Would it have been the most practical? Probably not. So, what then? Obviously, I could have used plain JavaScript to wire up some of the more subtle interactions, such as resetting the add lane button when focusing away. That would still have required a fair bit of boilerplate code to wire up the code in the first place, and to do things like DOM traversal and manipulation. Instead, I’ll use a language that is better suited for these kinds of small interactions: hyperscript, from the same guy who created htmx. In fact, it is advertised as the ‘companion of htmx’.

hyperscript is meant to be a hypermedia-friendly scripting language. This means, among others, that the language is designed to be used in-line (i.e. in an HTML attribute). JavaScript can be used in-line, of course, but it’s cumbersome and relatively verbose. Doing something simple like showing a single element would be trivial, but doing multiple things quickly starts to become hard to read. In-line JavaScript handlers also only handle a single type of event on a single element; if you need to handle an event on multiple elements, you’ll need to duplicate the same onclick attribute (or whatever attribute you need) for every element. For this reason, it’s recommended to write JavaScript separately from your HTML and write long, verbose statements describing what you want. That, or use jQuery.

document.addEventListener("DOMContentLoaded", function() {
  var button = document.querySelector(".add-lane-button");
  button.addEventListener("click", function() {
    var form = document.querySelector(".add-lane-form");
    form.style.display = "block";
    this.style.display = "none";
  });
});

Now contrast this with hyperscript, which has event handling and DOM manipulation built into the language.

<button _="on click 
             show the first .add-lane-form
             hide me">
  Add lane
</button>

_= is hyperscript for ‘apply the following behaviors to this element’. The behavior is simple: when the button is clicked, show the first element with the class add-lane-form, and then hide the button.

Obviously, this is a very simple example and a bit contrived, but it does work, and it showcases a philosophy behind both hyperscript and htmx: locality of behavior. It is much easier to understand what behaviors actually apply to a particular element when that behavior is defined in its close vicinity.

So, back to our main event. Let’s first tackle clicking the ‘add lane’ button, which we already partially solved in the sidebar. In hyperscript, this almost looks like pseudo-code.

<button _="on click 
             show next .add-lane-form 
             hide me 
             focus() on the first <input[type=text] /> in the next .add-lane-form">
  Add lane
</button>
<div class="add-lane-form" style="display:none">
  <!-- form goes here -->
  <input type="text" />
</div>

So: we have a button with some hyperscript and an invisible div with a text field inside it. The hyperscript looks similar to that of the sidebar. When the button is clicked, show the ‘next’ element with the class add-lane-form, then hide the button, and then something that looks complicated.

Let’s first address the ‘next’ bit. Like htmx, hyperscript supports relative queries, i.e. finding elements relative to the current element. next will find the next element in the DOM, which includes searching through parents and ancestors. In this case, the element is the button’s sibling, but it could be somewhere else entirely.

Then the complicated bit. We can break it down into parts by putting parentheses around those parts.

focus() on
  (the first <input[type=text] /> in
    (the next .add-lane-form))

Now it looks like more like a maths equation, and it’s solved in a similar manner; find the next element with class add-lane-form, and within that find the first input that has type="text", then call focus() on it.

So, now that we have a visible text field, let’s deal with pressing Enter and actually creating the lane. This is handled by htmx using the hx-post attribute. Let’s zoom out a bit and show the button, the form, and its container.

<div class="add-lane">
  <button _="-- omitted for brevity">
    Add lane
  </button>
  <div class="add-lane-form" style="display:none">
    <form hx-post="/boards/@Model.BoardId/lanes"
          hx-target=".add-lane"
          hx-swap="beforebegin">
      <input name="LaneName"
             type="text"
             required
             placeholder="Add a lane"/>
    </form>
  </div>
</div>

The form is the tag doing the heavy lifting here. It posts to a particular endpoint, and its target is the outermost div in this snippet, the one with the class add-lane, and its swap type is set to beforebegin. To visualize why this works, let’s look at a picture that visualizes the HTML structure.

Some lanes on a board with lay-out elements highlighted.

Witness my box drawing prowess.

Because of the magic of CSS Flex Box, just adding a div next to the others is enough for everything to be styled correctly. Because the <div class="add-lane"> is always at the end of the ‘strip’, beforebegin will correctly add lanes after the existing ones, and move the ‘add lane’ control to the right.

The endpoint that adds the new lane only returns the HTML for the newly added lane. At this point, we’re half-way there. After you’ve added a lane, the page now looks like this:

Some lanes on a board, but a new lane has been added. The input field is still visible and still has a value.

That text field has outstayed its welcome.

Validation

The text field we used to add the lane is still there, with its value. That’s okay for now; let’s focus on the ‘unhappy flow’ for adding a lane: the name is not unique, so we need to show an error message. How we find that the name is not unique is not that interesting here, but how we get the error message to show up is. If we simply return the error message to the client, it will be added to the DOM where the new lane would show up.

We need to change the effective target element, but that won’t be enough, since the swap method is beforebegin. We’ll also need to change the swap method. Fortunately, htmx supports this through some HTTP response headers. To keep things simple, we’ll just return the entire form with an error message, and we’ll change the target to be this (meaning the element that initiated the request, i.e. the form) and set the swap method to outerHTML, i.e. replace the entire element. If we send the HTTP response like the following, htmx will do what we want.

HTTP 200 OK
Content-Type: text/html
HX-Retarget: this
HX-Reswap: outerHTML

<form hx-post="/boards/1/lanes"
      hx-swap="beforebegin"
      hx-target=".add-lane">
  <input name="LaneName" value="Procrastinate" />
  <!-- and so on -->
  <span class="text-danger">Lane names must be unique.</span>
</form>

The HX-Retarget and HX-Reswap headers tell htmx to ignore the target and swap method originally set on the element making the request, and use the new ones instead. This is a nice mechanism for different flows on your page, which could be used for validation, but also for authentication, for example.

Hiding the field

Now that we have validation in place, let’s circle back to the previous problem: the text field is still visible, while we want it to disappear and the ‘add lane’ button to reappear. There are several ways we can tackle this problem.

Instead of adding only the newly added lane, we could replace the entire strip of lanes, including the ‘add lane’ button. That’s not very efficient, and it does not work for another requirement, namely hiding the text field and showing the button when we move focus out of the text field. Let’s call this plan C.

We could trigger a request after we’ve added a lane to replace the ‘add lane’ form. This might be a simple solution, but it also introduces a visible ‘flicker’ between the moment the lane is visually added and the text field is replaced with a hidden one. This happens even with blindingly fast back-ends on a local machine, so imagine how distracting it would be on a spotty 3G connection.

We could use out-of-band swapping to add the new lane and replace the form in a single action, but this is not super clean, because it requires a switch in the back-end logic to add the hx-swap-oob attribute or not, depending on the situation.

Perhaps the best solution is to use hyperscript to handle these events. Responding to the user moving the focus away is easy enough; we just hide the form, show the button, and then we reset the text field’s value to an empty string and we hide any validation errors.

<input name="LaneName"
       type="text"
       required
       placeholder="Add a lane"
       _="on blur
            hide <div.add-lane-form />
            show <button.add-lane-button/>
            set my value to ''
            hide the next .text-danger"/>

This is pretty straight-forward. However, doing this when a lane has been added seems less obvious. What kind of event can we handle that would indicate this? There’s the htmx:afterOnLoad event from htmx, but that will fire for any successfully completed request, including one where the response shows a validation error. There’s the htmx:afterSettle event, which fires on the <div class="add-lane"> when a lane has been added. This is fired very late in the process, though, and that leads to flickering, the thing we were trying to avoid. In addition, it’s pretty brittle; if we change how we add lanes, we could also end up needing to change this.

The proper solution is having the server trigger a custom event whenever a lane has been added. This is done using response headers: just put the name of an event in the HX-Trigger header, and htmx will trigger that event when the request completes successfully.

HTTP 200 OK
Content-Type: text/html
HX-Trigger: laneAdded

<!-- HTML goes here -->

These ‘custom events’ are proper JavaScript events, so they can be used to trigger htmx requests, or hyperscript blocks, or anything else that responds to JavaScript events. In our case, we would just add it to the existing on clause, changing it from on blur to on blur or laneAdded from body. The from qualification is needed because htmx triggers custom events on the body element.

<input name="LaneName"
       type="text"
       required
       placeholder="Add a lane"
       _="on blur or laneAdded from body
            hide <div.add-lane-form />
            show <button.add-lane-button/>
            set my value to ''
            hide the next .text-danger"/>

And that’s it, that is all of the behavior described at the start of this section covered: toggling a button into a text field that reverts when you cancel the operation or when you complete it successfully. For a complete view, this is all of the HTML that makes this happen:

<div class="add-lane">
  <button class="btn add-lane-button"
          _="on click 
               show next .add-lane-form 
               hide me 
               focus() on the first <input[type=text] /> in the next .add-lane-form">
    Add lane
  </button>
  <div class="add-lane-form"
       style="display: none">
    <form hx-post="/boards/@Model.BoardId/lanes"
          hx-swap="beforebegin"
          hx-target=".add-lane">
      <input name="LaneName"
             type="text"
             required
             placeholder="Add a lane"
             _="on blur or laneAdded from body
                  hide <div.add-lane-form/>
                  show <button.add-lane-button/>
                  set my value to ''
                  hide the next .text-danger"/>
    </form>
  </div>
</div>

Could we have used this for the editing of the board name as well? Yes, we very well could have, and that is what is in the final version of the code (as of this writing). It replaces 6 lines of htmx attributes with 8 lines of hyperscript, merges two files together, and removes two entire endpoints. Originally, I implemented it as described earlier, and as I was writing this part of the article I thought of exploring other places where I don’t really need a request to just change what elements are visible.

Creating cards

Now that we have lanes, creating cards should be a walk in the park. The structure of the UI for creating a card is almost identical to that for creating a lane; there’s a button that turns into a field, which disappears and resets when you actually create a card or dismiss the action. The htmx part is set up in very similar way, although it has a minuscule difference, which has to do with reordering cards, the subject of the next section.

<button _="on click
             show next .add-card-form 
             hide me 
             focus() the first <input[type=text] /> in the next .add-card-form">
    Add a card
</button>
<div class="add-card-form"
     style="display:none">
    <form hx-post="/boards/@Model.BoardId/lanes/@Model.LaneId"
          hx-target="previous .card-stack"
          hx-swap="beforeend">
        <input type="text"
               name="cardName" 
               required 
               placeholder="Add new card" 
               autocomplete="off"
               _="on blur or cardAdded from body
                    hide the closest .add-card-form
                    set my value to '' 
                    show <button.add-card-button/>"/>
    </form>
</div>

Reordering cards in a lane

Reordering items in a list, because that is what cards in a lane essentially are, is not as hard as it sounds. We can just add up and down buttons on each item that move them around within a lane. For a better experience, we can let users select an item, and move them around with centrally located up and down buttons. Except of course that’s not what we’re going to do. Trello (and by extension Trellix, and most modern work organization tools) lets you move around cards by clicking and dragging. How are we going to do this in our htmx set-up? Events!

We’ve used events in our approach to resetting the UI when a lane or card has been added, but then we only triggered events using the HX-Trigger header, which then triggered blocks of hyperscript. Now we’re going to send requests, triggered by JavaScript events.

We’ll use the SortableJS library to deal with the heavy lifting of dragging and dropping. It’s a simple, but capable library, and most importantly, it triggers a JavaScript event whenever a card has been moved to a different position.

SortableJS requires a tiny bit of JavaScript to set up, and a container element in the DOM with some child elements to reorder. In our case, each lane has its own sortable instance, the elements to reorder are the lane’s cards, and the container is a form that will be submitted whenever a card has been moved. Why a form? Because the way we encode the new sort order is by sending the IDs in a particular order.

Let’s say we have three cards with IDs A, B, and C. We’ll give each card a hidden input field with its ID and a common name. When you submit a form, the fields are added in tree order, meaning the order in which they appear in the DOM. In other words, if we add a hidden field called cards to each card, with its ID as the value, and we submit the form containing everything, the form data submitted will be this:

cards=A&cards=B&cards=C

When you reorder elements using SortableJS, they are simply repositioned in the DOM, which means the order in which the IDs appear in the form data will reflect the new order. SortableJS has many different events for different parts of the drag-and-drop cycle, but the sort event is triggered for ‘any change to the list (add / update / remove)’, which make it an ideal candidate, for now.

To put this into practice, let’s first configure the SortableJS library. After adding a script tag to the document, we can do this:

htmx.onLoad(function(content) {
    const sortables = content.querySelectorAll(".card-stack");
    sortables.forEach(sortable => {
        Sortable.create(sortable);
    });
});

In my example repository, there’s a few more lines of code to enable animations and to prevent sorting whenever there’s a request in-flight, but this is definitely functional.

Now we just need to add the cards and the form.

<form class="card-stack"
      hx-put="/boards/@Model.BoardId/lanes/@Model.LaneId/sortItems"
      hx-trigger="sort">
  @foreach (var card in Model.Cards)
  {
    <div class="lane-card">
      <input type="hidden" name="cards" value="@card.CardId" />
      <div class="card-text">@card.Title</div>
    </div>
  }
</form>

Now we just need to implement an endpoint that takes the card IDs in the new order, and uses that information to sort the cards in the application’s persistent storage. In this case, we don’t need a response, because nothing would change. If you would need to deal with concurrent updates, you might need to replace the items in the DOM after sorting them.

Moving cards between lanes

In the previous section we handled reordering items within a lane. But one of the USPs of Trello is being able to drag items to a different lane to indicate its progress. Obviously, we need to be able to do this. Fortunately, SortableJS supports this scenario, which they call ‘groups’. If we specify the same group name when setting up each sortable instance, we can freely move items between them.

htmx.onLoad(function(content) {
    const sortables = content.querySelectorAll(".card-stack");
    sortables.forEach(sortable => {
        Sortable.create(sortable, { group: "cards" });
    });
});

The only problem is that, because our htmx trigger is the sort event, moving a card from one lane to the other will trigger two requests; one for the lane the card is added to, and one for the lane the card is removed from. The second request will achieve nothing, so we’d rather not have it. We can use more specific events instead:

  • add for when an items is added to a lane (from another one);
  • update for when an items order within the lane is changed.

Using hx-trigger="add,update" will do what we want. Our endpoint implementation will need to be a little bit smarter, because the IDs it gets could be from any lane, not just the one that’s being sorted.

And voila, we can move cards every way we want, and it only took a few lines of code.

Removing cards

While removing cards sounds trivial, I thought it’s interesting to include because it uses some of the more interesting features of htmx to show how little you need for useful interactions.

One way of implementing this is to trigger a refresh of the entire lane. That would be wasteful, though, because in the end we just want to remove a single element from the DOM. The way to go is to use hx-swap="delete".

<a hx-delete="/boards/@Model.BoardId/cards/@Model.CardId"
   hx-target="closest .lane-card"
   hx-swap="delete"
   hx-params="none">
  Delete
</a>

When the DELETE request completes successfully (and returns a 2xx-class response), the target element (in this case: the entire card) is removed from the DOM. hx-params is there because the cards are all contained within a form, and by default, htmx will then send all of the form’s fields with the request. While it’s not a problem in this case, it’s better to send a correct request, and specifying hx-params="none" will do just that.

Card counting

This feature is not a part of Trellix, but even more interesting than removing cards, because it showcases updating multiple parts of the page. We’ll add a badges to indicate how many cards are in each lane, and on the board in total, and to do this we’ll be using several mechanisms.

A board with several lanes, each have one or more cards. Each lane has a badge showing the number of cards in the lane. The board title has a badge showing the total number of cards on the board.

A board with counters shown

A board with several lanes, each have one or more cards. Each lane has a badge showing the number of cards in the lane. The board title has a badge showing the total number of cards on the board.

To show the overall total number of cards on the page, we’ll use out-of-band swaps, since there is only a single element to update (and so we can show what that looks like). There are only two scenarios that affect the overall total: creating a card and removing a card.

Next to the name of the board, we’ll add a badge with an ID.

<span class="badge bg-primary"
      id="boardCardCount">
  @Model.CardCount
</span>

In my example repository, this is implemented using a view component, which is kind of a ‘lightweight controller’; while it is a component that’s invoked by the view, it has its own ‘controller’ that can execute code and that can be tested independently from its view. This means I don’t need to query the new total number of cards in my controller action that creates a new card, nor do I need to do this in a view.

When a card has been created, I add this badge element next to the newly created card, but with a hx-swap-oob="true" attribute. The value of the hx-swap-oob attribute can be true or an hx-swap value, optionally with a CSS selector. If set to true, htmx will look up the target element by its ID and replace its contents.

When a card is removed, the response consists only of this badge, with hx-swap-oob="true".

Lane card counts

In addition to creating and removing cards, reordering or moving cards can affect the number of cards in each lane. Each lane will have its own count, so in this case, we’ll have each element update itself in response to certain events.

We could trigger the cardAdded event when we create a card, but that would cause each lane’s card count element to refresh itself, which is a bit of a waste. Instead, we can trigger an event with the lane ID added (i.e. cardAdded:12345), and only have each element respond to events within its own lane.

<span class="badge bg-primary"
      hx-get="/boards/@Model.BoardId/lanes/@Model.LaneId/cardCount"
      hx-trigger="cardAdded:@Model.LaneId from:body"
      hx-swap="innerHTML">
  @Model.Cards.Count
</span>

We can do similar things when a card is removed and when cards have been sorted. The last case is slightly more complicated from an implementation point of view, since we’ll need to keep track of which lanes we affect when we sort items.

Note how the swap method is innerHTML (which it is by default, but I added it to be explicit). This means the response to refreshing the card count is literally just a number with the text/html content type, and that’s enough.

Before we leave: Alpine.js

A coworker who I asked to review this article said he found hyperscript’s syntax to be confusing and that he would prefer something a bit more ‘like JavaScript’. I briefly investigated what it would look like to use Alpine.js instead of hyperscript.

Like hyperscript, Alpine.js is very hypermedia-friendly, and can be ‘installed’ by adding a single script file to the page. Unlike hyperscript, it styles itself as a JavaScript framework, rather than a language.

Where hyperscript is event-driven but procedural, Alpine.js is ‘reactive’, which means there is a lot of magic happening, and when it doesn’t magic, figuring out what’s wrong could be a tall order.

That said, let’s look at what editing the title would look like using Alpine.js. First, as a reminder, let’s look at the hyperscript version. I’ve trimmed it down a bit for brevity’s sake.

<h1 _="on click
         hide me
         show the next <form />
         focus() on the first <input[type=text]/> in the next <form />">
  Title
</h1>
<form style="display: none">
  <input value="Title"
         _="on blur
              reset() the closest <form />
              hide the closest <form />
              show the previous <h1 />" />
</form>

In Alpine.js, everything is a ‘component’. To create a component, we need an enclosing element and add an x-data attribute to it. This also defines the initial state of the component. The main functionality we’re looking for is toggling between two elements, so we can represent that with a boolean.

<div x-data="{ edit: false }">
  <h1>
    Title
  </h1>
  <form>
    <input value="Title" />
  </form>
</div>

To show or not show elements, we can use the x-show attribute, which lets you declare, in JavaScript, when an element should be visible.

<div x-data="{ edit: false }">
  <h1 x-show="!edit">
    Title</h1>
  <form x-show="edit">
    <input value="Title" />
  </form>
</div>

The h1 is visible when edit is false, and the form is visible when edit is true. Now to set it to true when you click the h1, and to false when you focus away from the input. We can do that by handling events with x-on attributes.

<div x-data="{ edit: false }">
  <h1 x-show="!edit"
      x-on:click="edit = true">
    Title</h1>
  <form x-show="edit">
    <input value="Title"
           x-on:blur="edit = false" />
  </form>
</div>

Here, x-on:click means ‘when clicked, execute this JavaScript’. You can guess what x-on:blur does.

Now we just need the finishing touches: focusing the text field when we start editing, and resetting its value when the user cancels the edit. Because the x-on attribute contains JavaScript, we can just add a semicolon and add more statements. But how do we focus on the text field from the h1’s event handler? Enter the x-ref attribute; it lets you give elements addressable names within the scope of a component. The way you access them is through the magic3 $refs property.

<div x-data="{ edit: false }">
  <h1 x-show="!edit"
      x-on:click="edit = true; $refs.input.focus()">
    Title</h1>
  <form x-show="edit"
        x-ref="form">
    <input value="Title"
           x-on:blur="edit = false; $refs.form.reset()"
           x-ref="input" />
  </form>
</div>

I appreciate that this is somewhat easier to understand, particularly the $refs part. Does it work and fit with htmx? Yes, absolutely. I personally prefer hyperscript for its procedural approach, but if you like the likeness to JavaScript and the reactiveness, this might be a better fit.

Conclusion, reflection

And there you have it: a clone of Trellix, which is itself a clone of Trello, implemented mostly using htmx, with a sprinkling of JavaScript and hyperscript where it makes sense. I’ve certainly learned a thing or two about htmx, hyperscript, Alpine.js, and even HTML and CSS.

The original premise of this article was to respond to the statement that it would be ‘so hard’ to build something like Trellix using htmx, and I hope I’ve managed to dispel that claim.

I don’t think it’s possible to truly compare the codebase to that of Trellix, because there are too many differences, but I will say that I like the fact that my implementation contains a total of 24 lines of JavaScript (excluding libraries, of course) and 25 lines of hyperscript.

Now, while my version does not look as slick as Trellix, there’s a surprising amount of polish considering there are only 116 lines of CSS. Of course it’s leaning quite a bit on the shoulders of Bootstrap, but still, I’m impressed with the state of CSS in 2024.

Regarding the build process, the project file is 12 lines long, and literally the only change from the output of dotnet new was to add Khalid Abuhakmeh’s tiny Htmx.Net package, which adds some convenience methods to work with request and response headers.

What about…

I’m sure there are a lot of details of Trellix’ implementation that my version doesn’t capture; animations and such, or visual refinements. That was not the point of this article. I don’t want to be too hand-wavy about such details, but in my opinion, they fall into one of these buckets:

  1. They are easily implemented by using HTML and CSS, or a light sprinkling of hyperscript or (if you must) JavaScript.
  2. They’re cumbersome or impossible to implement (although the latter is improbable).

For the use case of Trellix, details that fall into the second bucket are generally not really that important for the customer experience, and certainly not worth the trouble of setting up a whole SPA application.


  1. The actual application uses a lot of CSS classes that would obviously make this article even harder to understand, and it uses some tag helpers for type safety and brevity. I opted to not use tag helpers in the article to make it easier to understand for those unfamiliar with them.

  2. Despite a lot of progress in the right direction, it’s not yet possible to use a standardized element or attribute, like <dialog> or popover to do this.

  3. Alpine.js’ documentation literally calls these properties ‘magic properties’.