Web users today expect the fluid, dynamic experiences that single-page applications (SPAs) deliver. However, creating SPAs often involves intricate frameworks like React and Angular, which can be complex to learn and work with. Enter htmx — a library that brings a fresh perspective to building dynamic web experiences by leveraging features such as Ajax and CSS transitions directly in HTML.
In this guide, we’ll explore the capabilities of htmx, how it simplifies dynamic web development, and how you can harness its potential to enhance your web development process.
What Is htmx and How Does It Work?
When building interactive web experiences, developers have traditionally had two main options, each with its own trade-offs. On one hand, there are multi-page applications (MPAs) which refresh the entire page every time a user interacts with it. This approach ensures that the server controls the application state and the client faithfully represents it. However, the full page reloads can lead to a slow and clunky user experience.
htmx provides a middle ground between these two extremes. It offers the user experience benefits of SPAs — with no need for full page reloads — while maintaining the server-side simplicity of MPAs. In this model, instead of returning data that the client needs to interpret and render, the server responds with HTML fragments. htmx then simply swaps in these fragments to update the user interface.
There are several ways to include htmx in your project. You could download it directly from the project’s GitHub page, or if you’re working with Node.js, you can install it via npm using the command
npm install htmx.org.
However, the simplest way, and the one we’ll be using in this guide, is to include it via a content delivery network (CDN). This allows us to start using htmx without any setup or installation process. Just include the following script tag in your HTML file:
This script tag points to version 1.9.4, but you can replace “1.9.4” with the latest version if a newer one is available.
htmx is very lightweight, with a minified and gzipped version weighing at ~14KB. It has no dependencies and is compatible with all major browsers, including IE11.
Once you’ve added htmx to your project, you might want to check that it’s working correctly. You can test this with the following simple example:
<button hx-get="https://v2.jokeapi.dev/joke/Any?format=txt&safe-mode&type=single" hx-target="#joke-container" > Make me laugh! </button> <p id="joke-container">Click the button to load a joke...</p>
When you click the button, if htmx is working correctly, it will send a GET request to the Joke API and replace the contents of the
<p> tag with the server’s response.
Ajax Requests: the htmx Approach
One of the main selling points of htmx is that it gives developers the ability to send Ajax requests directly from HTML elements by utilizing a set of distinct attributes. Each attribute represents a different HTTP request method:
hx-get: issues a GET request to a specified URL.
hx-post: issues a POST request to a stated URL.
hx-put: issues a PUT request to a certain URL.
hx-patch: issues a PATCH request to a set URL.
hx-delete: issues off a DELETE request to a declared URL.
These attributes accept a URL, to which they will send the Ajax request. By default, Ajax requests are triggered by the “natural” event of an HTML element (for example, a click in the case of a button, or a change event in the case of an input field).
Consider the following:
<button hx-get="/api/resource">Load Data</button>
In the above example, the
button element is assigned an
hx-get attribute. Once the button is clicked, a GET request is fired off to the
What happens when the data returns from the server? By default, htmx will inject this response directly into the initiating element — in our example, the
button. However, htmx isn’t limited to this behavior and provides the ability to specify different elements as the destination for the response data. We’ll delve more into this capability in the upcoming sections.
Triggering Requests with htmx
htmx initiates an Ajax request in response to specific events happening on certain elements:
selectelements, this is the
formelements, this is the
- For all other elements, this is the
Let’s demonstrate this by expanding our joke example from above to allow the user to search for jokes containing a specific word:
<label>Keyword: <input type="text" placeholder="Enter a keyword..." hx-get="https://v2.jokeapi.dev/joke/Any?format=txt&safe-mode" hx-target="#joke-container" name="contains" /> </label> <p id="joke-container">Results will appear here</p>
To trigger the search, we need to fire the change event. For
<input> elements, this occurs when the element loses focus after its value was changed. So type something into the box (such as “bar”), click elsewhere on the page, and a joke should appear in the
This is good, but normally users expect to have their search results updated as they type. To do this, we can add an htmx
trigger attribute to our
<input ... hx-trigger="keyup" />
Now the results are updated immediately. This is good, but it introduces a new problem: we’re now making an API call with every keystroke. To avoid this, we can employ a modifier to change the trigger’s behavior. htmx offers the following:
once: use this modifier if you want a request to be executed just once.
changed: this modifier ensures a request is only issued if the value of the element has been altered.
delay:<time interval>: this modifier sets a waiting period (like
1s) before the request is issued. Should the event trigger again during this waiting period, the countdown resets.
throttle:<time interval>: With this modifier, you can also set a waiting period (such as
1s) prior to issuing the request. However, unlike
delay, if a new event is triggered within the set time, the event will be disregarded, ensuring the request is only triggered after the defined period.
from:<CSS Selector>: This modifier lets you listen for the event on a distinct element instead of the original one.
In this case it seems that
delay is what we’re after:
<input ... hx-trigger="keyup delay:500ms" />
And now when you type into the box (try a longer word like “developer”) the request is only fired when you pause or finish typing.
As you can see, this allows us to implement an active search box pattern in only a few lines of client-side code.
In web development, user feedback is crucial, particularly when it comes to actions that may take a noticeable amount of time to complete, such as making a network request. A common way of providing this feedback is through request indicators — visual cues indicating that an operation is in progress.
htmx incorporates support for request indicators, allowing us to provide this feedback to our users. It uses the
hx-indicator class to specify an element that will serve as the request indicator. The opacity of any element with this class is 0 by default, making it invisible but present in the DOM.
When htmx makes an Ajax request, it applies an
htmx-request class to the initiating element. The
htmx-request class will cause that — or any child element with an
htmx-indicator class — to transition to an opacity of 1.
For example, consider an element with a loading spinner set as its request indicator:
<button hx-get="/api/data"> Load data <img class="htmx-indicator" src="/spinner.gif" alt="Loading spinner"> </button>
button with the
hx-get attribute is clicked and the request starts, the button receives the
htmx-request class. This causes the image to be displayed until the request completes and the class is removed.
It’s also possible to use an
htmx-indicator attribute to indicate which element should receive the
Let’s demonstrate this with our Joke API example:
<input ... hx-indicator=".loader" /> <span class="loader htmx-indicator"></span>
Note: we can grab some CSS styles for the spinner from CSS Loaders & Spinners. There are lots to choose from; just click one to receive the HTML and CSS.
This will cause a loading spinner to displayed while the request is in flight.
If we’re on a fast network, the spinner will only flash briefly when making the request. If we want to assure ourselves that it’s really there, we can throttle our network connection speed using our browser’s dev tools.
Or, just for fun (that is, don’t do this on a real app), we could configure htmx to simulate some network latency:
function sleep(milliseconds) const date = Date.now(); let currentDate = null; do currentDate = Date.now(); while (currentDate - date < milliseconds); document.body.addEventListener('htmx:afterOnLoad', () => sleep(2000); );
This utilizes htmx’s event system, which we can tap into to modify and enhance its behavior. Here, we’re using the
htmx:afterOnLoad event, which is triggered after the Ajax
onload has finished. I’m also using a sleep function from a SitePoint article on the same subject.
Targeting Elements & Swapping Content
In some cases, we might want to update a different element than the one that initiated the request. htmx allows us to target specific elements for the Ajax response with the
hx-target attribute. This attribute can take a CSS selector, and htmx will use this to find the element(s) to update. For example, if we have a form that posts a new comment to our blog, we might want to append the new comment to a comment list rather than updating the form itself.
We actually saw this in our first example:
<button hx-get="https://v2.jokeapi.dev/joke/Any?format=txt&safe-mode&type=single" hx-target="#joke-container" > Make me laugh! </button>
Instead of the button replacing its own content, the
hx-target attribute states that the response should replace the content of the element with an ID of “joke-container”.
Extended CSS selectors
htmx also offers some more advanced ways to select elements into which content should be loaded. These include
thiskeyword specifies that the element with the
hx-targetattribute is the actual target.
closestkeyword finds the closest ancestor of the source element that matches the given CSS selector.
previouskeywords find the following or preceding element in the DOM that matches the given CSS selector.
findkeyword locates the first child element that matches the given CSS selector.
With reference to our previous example, we could also write
hx-target="next p" to avoid specifying an ID.
By default, htmx will replace the content of the target element with the Ajax response. But what if we want to append new content instead of replacing it? That’s where the
hx-swap attribute comes in. This attribute lets us specify how the new content should be inserted into the target element. The possible values are
hx-swap="beforeend", for example, would append the new content at the end of the target element, which would be perfect for our new comment scenario.
CSS Transitions with htmx
htmx makes it easy to use CSS Transitions in our code: all we need to do is maintain a consistent element ID across HTTP requests.
Consider this HTML content:
<button hx-get="/new-content" hx-target="#content"> Fetch Data </button> <div id="content"> Initial Content </div>
After an htmx Ajax request to
/new-content, the server returns this:
<div id="content" class="fadeIn"> New Content </div>
Despite the change in content, the
<div> maintains the same ID. However, a
fadeIn class has been added to the new content.
We can now create a CSS transition that smoothly transitions from the initial state to the new state:
.fadeIn animation: fadeIn 2.5s; @keyframes fadeIn 0% opacity: 0; 100% opacity: 1;
When htmx loads the new content, it triggers the CSS transition, creating a smooth visual progression to the updated state.
Using the View Transitions API
The new View Transitions API provides a way to animate between different states of a DOM element. Unlike CSS Transitions — which involve changes to an element’s CSS properties — view transitions are about animating changes to an element’s content.
The View Transitions API is a new, experimental feature currently in active development. As of this writing, this API is implemented in Chrome 111+, with more browsers expected to add support in the future (you can check its support on caniuse). htmx provides an interface for working with the View Transitions API, and falls back to the non-transition mechanism in browsers where the API isn’t available.
In htmx, there are a couple of ways to use the View Transitions API:
- Set the
htmx.config.globalViewTransitionsconfig variable to
true. This will use transitions for all swaps.
- Use the
transition:trueoption in the
View Transitions can be defined and configured using CSS. Here’s an example of a “bounce” transition, where the old content bounces out and the new content bounces in:
@keyframes bounce-in 0% transform: scale(0.1); opacity: 0; 60% transform: scale(1.2); opacity: 1; 100% transform: scale(1); @keyframes bounce-out 0% transform: scale(1); 45% transform: scale(1.3); opacity: 1; 100% transform: scale(0); opacity: 0; .bounce-it view-transition-name: bounce-it; ::view-transition-old(bounce-it) animation: 600ms cubic-bezier(0.4, 0, 0.2, 1) both bounce-out; ::view-transition-new(bounce-it) animation: 600ms cubic-bezier(0.4, 0, 0.2, 1) both bounce-in;
In the htmx code, we use the
transition:true option in the
hx-swap attribute, and apply the
bounce-it class to the content that we want to animate:
<button hx-get="https://v2.jokeapi.dev/joke/Any?format=txt&safe-mode" hx-swap="innerHTML transition:true" hx-target="#joke-container" > Load new joke </button> <div id="joke-container" class="bounce-it"> <p>Initial joke content goes here...</p> </div>
In this example, when the
<div>‘s content is updated, the old content will bounce out and the new content will bounce in, creating a pleasing and engaging visual effect.
Please keep in mind that, currently, this demo will only work on Chromium-based browsers.
htmx integrates well with the HTML5 Validation API and will prevent form requests from being dispatched if user input fails validation.
For example, when the user clicks Submit, a POST request will only be sent to
/contact if the input field contains a valid email address:
<form hx-post="/contact"> <label>Email: <input type="email" name="email" required> </label> <button>Submit</button> </form>
If we wanted to take this a step further, we could add some server validation to ensure that only
gmail.com addresses are accepted:
<form hx-post="/contact"> <div hx-target="this" hx-swap="outerHTML"> <label>Email: <input type="email" name="email" required hx-post="/contact/email"> </label> </div> <button>Submit</button> </form>
Here we’ve added a parent element (
div#wrapper) that declares itself as the recipient of the request (using the
this keyword) and employs the
outerHTML swap strategy. This means that the entire
<div> will be replaced by the server’s response, even though it’s not the actual element triggering the request.
We’ve also added
hx-post="/contact/email" to the input field, which means that whenever this field is blurred, it will send a POST request to the
/contact/email endpoint. This request will contain the value of our field.
On the server (at
/contact/email), we could do the validation using PHP:
<?php $email = $_POST['email']; $pattern = "/@gmail\.com$/i"; $error = !preg_match($pattern, $email); $sanitizedEmail = htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); $errorMessage = $error ? '<div class="error-message">Only Gmail addresses accepted!</div>' : ''; $template = <<<EOT <div hx-target="this" hx-swap="outerHTML"> <label>Email: <input type="email" name="email" hx-post="/contact/email" value="$sanitizedEmail"> $errorMessage </label> </div> EOT; echo $template; ?>
As you can see, htmx is expecting the server to respond with HTML (not JSON) which it then inserts into the page at the specified place.
If we run the above code, type a non-
gmail.com address into the input, then make the input lose focus, an error message will appear below the field stating “Only Gmail addresses accepted!”
Note: when inserting content into the DOM dynamically, we should also think about how a screen reader will interpret this. In the example above, the error message finds itself inside our
label tag, so it will be read by a screen reader the next time the field receives focus. If the error message is inserted elsewhere, we should use an aria-describedby attribute to associate it with the correct field.
<form hx-post="/contact"> <label>Email: <input type="email" name="email" required> </label> <button>Submit</button> </form> <script> const emailInput = document.querySelector('input[type="email"]'); emailInput.addEventListener('htmx:validation:validate', function() const pattern = /@gmail\.com$/i; if (!pattern.test(this.value)) this.setCustomValidity('Only Gmail addresses accepted!'); this.reportValidity(); ); </script>
Here, we’re using htmx’s
htmx:validation:validate event, which is called before an elements
checkValidity() method is called.
Now when we try and submit the form with a non-
gmail.com address, we’ll see the same error message.
What Else Can htmx Do?
Before we wrap up, let’s have a quick look at some of these additional capabilities.
You can find a list of available extensions on the htmx site.
htmx’s “boosting” functionality allows us to enhance standard HTML anchors and forms by transforming them into Ajax requests (akin to technologies like pjax from back in the day):
<div hx-boost="true"> <a href="/blog">Blog</a> </div>
The anchor tag in this div will issue an Ajax
GET request to
/blog and swap the HTML response into the
By leveraging this feature, we can create more fluid navigation and form submission experiences for our users, making our web applications feel more like SPAs.
Speaking of SPAs, htmx also comes with built-in history management support, aligning with the standard browser history API. With this, we can push URLs into the browser navigation bar and store the current state of the page in the browser’s history, ensuring that the “Back” button behaves as users expect. This allows us to create web pages that feel like SPAs, maintaining state and handling navigation without reloading the entire page.
Use with a third-party library
One of the nice things about htmx is its ability to play well with others. It can integrate seamlessly with many third-party libraries, utilizing their events to trigger requests. A good example of this is the SortableJS demo on the htmx website.
There’s also a confirm example which shows how to use sweetalert2 for confirmation of htmx actions (although this also uses hyperscript, an experimental frontend scripting language designed to be expressive and easily embeddable directly in HTML).
As web development continues to evolve, tools like htmx provide exciting new ways to build better experiences for users. Why not give it a try on a future project and see what htmx can do for you?