HTMX is a JavaScript library that allows you to make ajax calls and create CSS transitions without writing any JavaScript code. It works by adding attributes to HTML elements, which it then uses to set up and perform ajax requests, swap elements, and a few other things.
It was added to Drupal in version 11.3.0* and gives developers the ability to create interactive elements using render arrays and HTML attributes. The intent is to replace the entire ajax sub-system with one built around HTMX, and there is quite a lot of work ahead to accomplish this task.
* Technically, HTMX has been in Drupal since 11.2.0, but only as an experimental library. Drupal 11.3.0 features the full HTMX library and a number of helper classes to make life easy.
In this article we will look at how HTMX is integrated into Drupal, and what services exist to help you use it within the Drupal system.
Since this article is quite long I have created a table of contents to assist in scrolling to the relevant sections.
First, let's dive into a (very) brief introduction to HTMX.
HTMX is a platform agnostic framework that allows to make ajax calls and CSS transitions without writing JavaScript code.
At just 16K the HTMX library is quite small, especially when compared to the 30K of the jQuery library, which Drupal has been working towards removing for a number of years. HTMX has no other dependencies and so that 16K really is the entire library.
By platform agnostic I mean that it doesn't care what is sat behind it, you can have a HTMX powered front end and pretty much any technology behind the scenes to respond to the HTTP requests. HTMX sends normal web requests to the backend and expects the backend to respond with plain HTML. Drupal is excellent at producing HTML and so is well positioned to be a decent backend system for HTMX to use.
HTMX is a variety of features at its core, but it also has a set of event hooks that plugins can tie into and add features and functionality. This means that if the core HTMX library doesn't do what you need, you can just extend it and add a little bit of code that fulfils your requirement.
Fundamentally, you can think of HTMX in the following way:
- Adds behaviour to HTML using attributes.
- Any element can issue a web request.
- All responses should be in HTML.
HTMX attributes are prefixed with with hx-* or data-hx-*, but either is fine to use. There are a number of different attributes available, but only around 11 of those are commonly used, with another 20-30 that add more functionality.
As an example, let's look at some HTML that isn't part of Drupal. This contains a couple of HTMX attributes.
<button hx-get="index.php" hx-target="#div1">Submit</button>
<div id="div1"></div>
The attributes in the above example will perform the following actions.
hx-get = Send a GET request to the index.php path.hx-target = Write the response in the element with the ID "div1".
The response to this request should be with plain HTML, which means we could respond with something like the following.
<p>Button clicked!</p>
Which would be injected into our div element like this.
<div id="div1"><p>Button clicked!</p></div>
This is pretty much the functionality of HTMX in a nutshell, the other attributes change how this central process of request and replace works.
You can see this example (and a lot more examples beside this) in a HTMX with PHP example repository that I have created. This includes a HTMX PHP class that wraps the detection and responding of HTMX requests to make the examples easier to understand.
Inside Drupal, you can get quite far with HTMX and Drupal by understanding just a few key attributes, so let's look into them. Note that I have deliberately used some simplifications here, but the key concepts are present. The HTMX.org reference page also has great documentation on this so please refer to that for more information.
Different HTTP verbs are available through attributes, we have already seen the hx-get attribute, but there are a few more.
hx-get does a "get" request.hx-post does a "post" request.hx-delete does a "delete" request.hx-patch does a "patch" request.hx-put does a "put" request.
This allows you to send a request back to the server using a HTTP verb that isn't normally used by HTML (i.e. not GET or POST).
This stipulates the target element to be swapped, which is defined with a CSS selector.
<button hx-get="index.php" hx-target="#div1">Submit</button>
<div id="div1"></div>
Defines the action that will trigger the request. This can be left out for elements that have default triggers. Links and buttons already have "click" events, which they will pass to HTMX if it is configured to do things on that element. Other form elements like select and input elements generate "change" events.
Here are some examples of the trigger element in use in a few different ways.
Add a click trigger to a div element.
<div hx-post="index.php" hx-trigger="click">Click</div>
Note that this isn't best practice and you should probably also include ARIA attributes in order to make the click as accessible as possible. The example here is focused on the HTMX, but HTML shouldn't live in isolation.
Trigger a click event, augment it with a once to only allow that event to be run once. This means that when a user click on it, the button will not issue another ajax request after this.
<button hx-get="index.php" hx-trigger="click once">Click</button>
Add a load and a click trigger to a single div tag. This means that we can trigger the element to load content in when it is loaded onto the page, and also when the user clicks on it.
<div hx-post="index.php" hx-trigger="load, click">Load & Click</div>
The revealed trigger will not trigger the event until the element is visible in the view port of the browser window.
<div hx-post="index.php" hx-trigger="revealed"></div>
Triggers can be augmented with delays to offset the request triggered by the event slightly. The following will trigger a request to index.php when the element is loaded onto the page, but only after a 500ms delay.
<div hx-post="index.php" hx-trigger="load delay:500ms"></div>
HTMX also understands the concept of "debouncing". In the following example we delay a the key up trigger by 1 second, and if the user pressed another key in that time then the previous event will be cancelled and a new one is created. This means that we don't trigger lots of events, just the last one in the sequence.
<input hx-get="index.php" hx-trigger="keyup delay:1s" />
This attribute will select content to swap with the target element from the response, using a CSS selector to identify the element.
Take the following example. Here we have a hx-target attribute telling us where the result will be placed, but we also have a hx-select attribute. This will be used to select out of the response the element we want to inject in the target element.
<button hx-get="index.php" hx-select="#response" hx-target="#div1">Click</button>
<div id="div1"></div>
Let's say we had the following response from the server.
<p id="response">Button clicked!</p>
<p>Some extra content that won't get displayed.</p>
The element with the ID of "response" will be picked out of the response and used as the content. Everything else will be ignored.
This attribute in particular is very important in Drupal. This is explained more later in the article, but when Drupal responds to a request to HTMX it will respond with a full page of HTML, meaning that we need to use the hx-select attribute to pick the elements we are interested in out of the response.
The "oob" part here refers to the concept of "out-of-band", which means we will be running actions that aren't necessarily connected to what the user is doing at the time. This attribute allows us to perform two actions with the response.
Take the following example.
<button hx-get="index.php" hx-select-oob="#div1">Send Request</button>
<div id="div1"></div>
First, we will trigger a GET request to the index.php script, which might return content that looks like this.
Request Sent
<div id="div1">Button clicked!</div>
The text of the button will be replaced with the text "Request Sent", which is the first part of the request. Then, the element with the ID of "div1" will be picked out of the request and used to replace the same element within the page. By default, HTMX will "swap" elements using an innerHTML strategy. It is important to remember that the default behaviour of hx-select-oob is outerHTML, which means that the entire element is replaced.
The hx-select-oob attribute can accept a comma separated list of elements, so the following example will change two elements from the response, as long as they are present in both places.
<button hx-get="index.php" hx-select-oob="#div1,#div2">Send Request</button>
<div id="div1"></div> <div id="div2"></div>
This controls how an element will be swapped into the page. By default this is innerHTML, which will replace the contents of elements with the response targets. Changing this to outerHTML will replace the entire element. You can also set this to textContent, which will replace contents without parsing it as HTML.
Other options include:
beforebegin, afterbegin, beforeend, afterend - Place the response before or after the element and its children.delete - Delete the target element from the page.none - Do nothing with the response (but still process out of band items).
Swap an "out-of-band" element. Similar to hx-select-oob but the response tells HTMX what to swap into the content of the page.
Take the following example. Note that we set the hx-swap attribute to "none", which means that we won't be performing any swapping at all with this element.
<button hx-get="index.php" hx-swap="none">Send Request</button>
<div id="div1"></div>
The response from the server contains a div with the ID of "div1" and the hx-swap-oob attribute set to true.
<div id="div1" hx-swap-oob="true">Button clicked!</div>
What this will do is find and element on the page with the same ID and swap it for this element. This essentially allows us to control elements on the page from the server. This is quite powerful.
HTMX supports the use of hx-* and data-hx-* attributes. Drupal has selected to use the data-hx-* attribute as the default. You can still use the hx-* attribute if you want, but Drupal will output the data-hx-* style by default when rendering HTMX.
HTMX is included into the page using one or two Drupal libraries. These libraries are as follows:
core/htmx - The core HTMX library.core/drupal.htmx - The core HTMX library and some additional scripts for integration with Drupal.
It should be noted that you rarely need to inject these libraries directly. When you interact with HTMX in Drupal it will automatically inject the libraries into the page request for you.
The core/drupal.htmx library includes three files that assist Drupal when using HTMX.
htmx-assets.js - This looks at the response from Drupal and pulls out any CSS and JavaScript references in the received markup. These assets are then injected into the header of the current page, which means that any CSS or JavaScript becomes part of the current page and is used to render the response. This is quite powerful as it means we can render anything we need to on the back end, and then be sure that they will appear correctly on the front end.htmx-behaviors.js - This connects to the Drupal.behaviors system so that any DOM changes that HTMX makes can be registered with Drupal behaviours and used to run events. By adding this file we can ensure that HTMX elements work with existing components already on Drupal pages.htmx-utils.js - This file contains some helper functions for the other two files.
To accompany these libraries we also have a number of PHP classes in the backend.
The \Drupal\Core\Htmx class is used as a wrapper around the HTMX libraries and attribute injection for render arrays. It can also inject headers for responding to HTMX. To use it you just need to create a new Htmx object, tell it what sort of HTMX attributes you want in your element, and then apply this to a render element.
For example, here is a simple example where we take a render array element and apply the data-hx-swap and data-hx-trigger attributes to the element.
$output['element'] = [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => 'Content'
];
$htmx = new Htmx();
$htmx->get()
->swap('afterend')
->trigger('revealed')
->applyTo($output['element']);
When rendered, this will generate the following HTML.
<p data-hx-get="/route" data-hx-swap="afterend" data-hx-trigger="revealed">Content</p>
You can see that the methods map to their equivalent attributes in the HTMX library, so the trigger() method will create a data-hx-trigger attribute, and so on.
We call the get() method to set up a GET request. This method can take a single parameter of the route that it should use to perform the GET request. If we leave this parameter out (like we have here) then we assume that the current route is used.
By using this mechanism we automatically include the core/drupal.htmx library in the page attachments. This means that if you add a single HTMX element to your page then you get everything you need to render and use that element correctly on the front end automatically.
You might also see syntax that looks like this, which is essentially the same thing. This uses a syntax is called "class member access on instantiation", and has been in PHP since version 5.4, although it isn't common to see.
(new Htmx())
->get()
->swap('afterend')
->trigger('revealed')
->applyTo($output['element']);
In addition to the Htmx class we also have a \Drupal\Core\Htmx\HtmxRequestInfoTrait trait, which is used to detect HTMX requests and associated headers. This trait needs access to the request_stack service and a method called getRequest() in order to pull information from the current request.
I haven't detailed this here, but HTMX issues a number of different headers that you can listen to in your code to get more information about the request. This can tell you things like what element triggered the request.
Drupal has the ability to respond to a HTMX response with a little HTML document containing the markup you created, which uses the class \Drupal\Core\Render\MainContent\HtmxRenderer.
You can respond with using the HtmxRenderer service directly, but it is often easier to invoke it through a couple of different mechanisms. This can be either adding the query parameter _wrapper_format=drupal_htmx to the URL being called, or by using a route with the _htmx_route=true option added to the route parameter.
To add the _wrapper_format to the route you can do something like this when setting up the request.
$htmx->get(Url::fromRoute(route_name: 'my_htmx_route', options: [
'query' => [
'_wrapper_format' => 'drupal_htmx',
],
]));
The _htmx_route=true on the route responding to the request.
mymodule_controller_action_htmx:
path: '/htmx-response'
defaults:
_controller: '\Drupal\mymodule\Controller\MyModuleController::htmx'
requirements:
_permission: 'access content'
options:
_htmx_route: TRUE
The important thing about the response from Drupal is that it is a full page of HTML, including the head and body elements. This means that you may need to pick out the elements you want to update from the page request, which is especially the case with forms.
Which option you use here depends on what you are doing. Let's break down using HTMX with controllers and forms separately to show any differences.
To integrate HTMX with your controller you need to think about your approach. You can either have:
- One route: with logic to separate out the HTMX response inside that route.
- Two routes: one for rendering the page, one marked with
_htmx_route: TRUE for the HTMX response.
If you have a single route then you'll need to use the HtmxRequestInfoTrait in the controller to detect if the incoming request is a HTMX request or not.
To use the HtmxRequestInfoTrait trait you need to add the request stack service and include a method called getRequest(). The following is a template for this that gives you everything you need.
namespace Drupal\mymodule\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
class MyController extends ControllerBase {
use HtmxRequestInfoTrait;
public function __construct(protected RequestStack $requestStack) {}
protected function getRequest() {
return $this->requestStack->getCurrentRequest();
}
...
With this in place you can then do something like the following in your action.
public function action() {
if ($this->isHtmxRequest()) {
// Respond to HTMX request.
}
// Respond to normal controller action.
// Generate markup to set up the HTMX request.
}
Here, we are detecting the incoming request by looking for the hx-request header, which HTMX always sends with the request. We could alternatively make HTMX issue a PUT request and then detect that HTTP method in the controller.
public function action() {
if ($this->getRequest()->getMethod() === 'PUT') {
// Respond to HTMX request.
}
// Respond to normal controller action.
// Generate markup to set up the HTMX request.
}
Note that if you do this then you also need to make sure that the controller action can receive PUT and GET requests by setting the route methods parameter.
If you want to use two routes to handle HTMX pages then you'll need to create a route that will respond to the HTMX request, separate from the original action. Actually, the bit of code that initiates the HTMX request can be in anything really, a block, another controller action, even just hard coded into a template (assuming that the HTMX library is also present). It just needs to be some markup on the page that will trigger the request.
The following shows a route with the _htmx_route: TRUE option set and the methods option set to accept PUT requests.
mymodule_controller_action_htmx:
path: '/htmx-response'
defaults:
_controller: '\Drupal\mymodule\Controller\MyModuleController::htmx'
requirements:
_permission: 'access content'
methods: [PUT]
options:
_htmx_route: TRUE
The controller action for this route will automatically use the HtmxRenderer service to render and respond to the request.
Forms work in much the same way as controllers, but here you need to decorate the elements inside the buildForm() method.
Unlike the current (or old) ajax sub-system in Drupal there is no need to create callback functions that return just the form elements we are interested in. In the HTMX implementation the entire page containing the form is returned from the backend so you need to pull out the elements you want to update from the response. You therefore need to wrap interactive elements in using the Htmx object and point HTMX at other elements in the form using attributes like data-hx-select.
As a quick example, to make an input element call back to the form after the user has finished typing we might do something like this.
public function buildForm(array $form, FormStateInterface $form_state) {
$form['text'] = [
'#type' => 'textfield',
'#title' => $this->t('Text'),
];
(new Htmx())
->post()
->target('*:has(>input[name="text"])')
->select('*:has(>input[name="text"])')
->trigger('keyup delay:1s')
->applyTo($form['text']);
return $form;
}
The input selector of the target() and select() methods here look at the parent element of the text element. This means that we tell HTMX that the elements surrounding the input field (which contain the label and description of the field) should be swapped with the same element in the response.
When the user finishes typing for 1 second, a request is made to the server, and the form is replaced with a copy of itself. Alright, so this isn't that useful, but this is a good start for working with HTMX.
If you miss out the select attribute then you'll find that the page containing the form will be placed inside itself, which isn't useful, but you will immediately know that you've done something wrong if you see this form in a form structure.
To do something useful with the request we need to use HtmxRequestInfoTrait, which is part of the FormBase class. The request_stack service is also part of the form so there is no need to inject this. This means that we can do something like this in the form to act upon the incoming HTMX request.
public function buildForm(array $form, FormStateInterface $form_state) {
if ($this->isHtmxRequest()) {
// React to HTMX request.
}
// Rest of the form.
}
Most importantly, forms need to be consistent! You can't just throw elements into the form markup as the elements need to exist in the form build. If you think about how Drupal forms work they are always built before they are submitted, so the form elements need to exist in the form build step in order for them to exist inside the submit handler.
In my work with HTMX and Drupal forms I have found that you don't need to update the CSRF ID of the form during these updates. You can work with just the fields you are interested in without causing the form to become invalid, as long as the form elements you add are part of the form build process.
This is pretty much it for the theory behind using HTMX in Drupal. Rather than fill this article with masses of code I thought it would be better to break this up into parts. This article is already quite long and I have certainly simplified a few things to make it shorter. I will therefore be creating more articles about HTMX and Drupal in the coming weeks, looking at different ways of using the system.
This system is fully working in it's current state, but there is very little HTMX functionality built into Drupal core currently. In fact, one of the only forms to currently work through HTMX is the single item configuration export form (on the page /admin/config/development/configuration/single/export).
The ultimate aim of replacing the current ajax sub-system with HTMX means that everything in core using the old system will need to be replaced; including all of the ajax command interfaces and other code associated with this. As you can imagine, this is quite a bit of work!
Not only that, but we have many contributed Drupal modules that use the current ajax sub-system and they will need updating to use HTMX.
Unlike the object oriented hooks the HTMX replacement it not really backwards compatible and so modules will either need to support both versions, or simply stop supporting the old ajax subsystem.
Due to the amount of work to be done and the (probably) slow uptake of support for HTMX in the contributed module space I can see that the current ajax sub-system won't be removed until at least Drupal 13 (maybe even beyond that). The "old" and new HTMX systems will certainly live side by side for the time being.
If you like what you see here and want to help then you can find out about the HTMX initiative on their community initiatives page. They are also quite active on the #htmx channel in the Drupal Slack instance.
If you've searched for Drupal and HTMX online you may have seen the HTMX module so I should probably mention it here. The module contains some extra tools and examples of HTMX used in a Drupal setting. This includes a Views plugin to display Views as HTMX.
There is also a neat little HTMX debugger that logs all HTMX events in the browser console.
It seems as though the module is a playground for features that might eventually make their way into Drupal core in the future. It's worth having a look at the module to see some examples of more advanced usage and some of the future plans for HTMX in core.
If you want to find out more about this then here are some useful links.
Comments
You've put a lot of work into that, Phil. I'm so glad to see someone tackling this topic and I'm looking forward to your follow-up articles. If I can get my head around the implementation, I'll use it to fetch the body content of another node (specifically, comment guidelines, T&C, etc.) in a modal pop-up. The old jQuery way is easy but mired in major shortcomings, so I don't use it.
Submitted by Ade on Tue, 03/17/2026 - 13:56
Thanks for reading and commenting Ade! Follow up articles on the way!
I hadn't thought of modal windows, but that's a good point. I'll make a note of that so I don't forget to talk about it :)
Submitted by philipnorton42 on Tue, 03/17/2026 - 14:31
Instead of rendering the entire page, you can also just render the content (for better performance!)—the easiest way to do this is:
(new Htmx())
->post()
->onlyMainContent()
....)
Submitted by Klemens Heinen on Thu, 03/19/2026 - 17:04
Oh! Thanks Klemens! Really good tip! :)
Submitted by philipnorton42 on Thu, 03/19/2026 - 19:39
Add new comment