A new toolset in Drupal core
DrupalCon Chicago: 2026
Shawn Duncan
Principal Software Engineer, Memorial Sloan Kettering Cancer Center© 2026. This work is openly licensed via CC
BY-SA 4.0.
This presentation reflects my own perspective and does not represent the views or opinions of Memorial Sloan Kettering Cancer Center (MSK)
HTMX
Background and Theory
Strong reputation in the Javascript ecosystem
A bar graph showing the stars gained by projects in 2023: React 16900, htmx 15600, Svelte 10300, Million 8200, Vue.js 7900
A bar graph showing the stars gained by projects in 2024: htmx 16800, React 14200, Svelte 6100, Million 5900, Vue.js 3500
A bar graph showing the stars gained by projects in 2025: [React 11000, Ripple 6500, Svelte 4600, htmx 4500, Vue.js 4300
HTMX is small
49.9 kB minified on GitHub
HTMX is Declarative
HTMX extends and processes HTML.
Any element can issue an HTTP request
Any event can trigger an HTTP request
Use all the “verbs” of HTTP
- GET (retrieves):
data-hx-get="/path" - POST (creates):
data-hx-post="/path" - PUT & PATCH (alter):
data-hx-put="/path"data-hx-patch="/path"
- DELETE (deletes):
data-hx-delete="/path"
Target any element in the document for replacement
HTMX Hypermedia Model
- Some interaction occurs.
- Make a request.
- Select something from the response.
- Swap the selected element into the page.
HTMX Can Also Call Javascript
A less common model
- Some interaction occurs.
- Call some JavaScript function
The Toolset
Process attachments (CSS/JS)
- Feature parity: Continues to function as you expect.
- On render, the current set of CSS & JS are compared with the new request.
- Only new assets are sent.
- Drupal javascript behaviors are attached to inserted code.
- HTMX attributes are processed on inserted code.
- See
\Drupal\Core\Render\HtmlResponseAttachmentsProcessor::processAttachmentsandcore/misc/htmx/htmx-assets.js
Factory object for HTMX headers and attributes
\Drupal\Core\Htmx\Htmx- A method for every HTMX attribute.
- A method for every HTMX response header.
- DocBlocks link to HTMX documentation.
- Similar pattern to
CacheableMetadata::applyTo - Static method
Htmx::createFromRenderArrayfor when you want to modify the HTMX properties in an existing render array.
$url = Url::fromRoute('some.route');
(new Htmx())
->get($url)
->select('div.new-stuff')
->target('div.existing-stuff')
->swap('beforeend')
->applyTo($build['something']);
Support dynamic forms using HTMX
(new Htmx())
->swapOob('outerHTML:input[name="form_build_id"][value="' . $old_build_id . '"]')
->applyTo($form['form_build_id']);
See core/lib/Drupal/Core/Form/FormBuilder.php:765 for the full logic.
Determine which elements are involved in an HTMX Request
- Use
\Drupal\Core\Htmx\HtmxRequestInfoTrait - Already included in
FormBase
\Drupal\Core\Htmx\HtmxRequestInfoTrait
/**
* Gets the request object.
*
* @return \Symfony\Component\HttpFoundation\Request
* The request object.
*/
abstract protected function getRequest();
/**
* Determines if the request is sent by HTMX.
*
* @return bool
* TRUE if the 'HX-Request' header is present.
*/
protected function isHtmxRequest(): bool {
return $this->getRequest()->headers->has('HX-Request');
}
/**
* Determines if the request is boosted by HTMX.
*
* @return bool
* TRUE if the 'HX-Boosted' header is present.
*/
protected function isHtmxBoosted(): bool {
return $this->getRequest()->headers->has('HX-Boosted');
}
/**
* Retrieves the URL of the requesting page from an HTMX request header.
*
* @return string
* The value of the 'HX-Current-URL' header, or an empty string if not set.
*/
protected function getHtmxCurrentUrl(): string {
return $this->getRequest()->headers->get('HX-Current-URL', '');
}
/**
* Determines if if the request is for history restoration.
*
* Sent after a miss in the local history cache
*
* @return bool
* TRUE if the 'HX-History-Restore-Request' header is present.
*/
protected function isHtmxHistoryRestoration(): bool {
return $this->getRequest()->headers->has('HX-History-Restore-Request');
}
/**
* Retrieves the prompt from an HTMX request header.
*
* @return string
* The value of the 'HX-Prompt' header, or an empty string if not set.
*/
protected function getHtmxPrompt(): string {
return $this->getRequest()->headers->get('HX-Prompt', '');
}
/**
* Retrieves the target identifier from an HTMX request header.
*
* @return string
* The value of the 'HX-Target' header, or an empty string if not set.
*/
protected function getHtmxTarget(): string {
return $this->getRequest()->headers->get('HX-Target', '');
}
/**
* Retrieves the trigger identifier from an HTMX request header.
*
* @return string
* The value of the 'HX-Trigger' header, or an empty string if not set.
*/
protected function getHtmxTrigger(): string {
return $this->getRequest()->headers->get('HX-Trigger', '');
}
/**
* Retrieves the trigger name from an HTMX request header.
*
* @return string
* The value of the 'HX-Trigger-Name' header, or an empty string if not set.
*/
protected function getHtmxTriggerName(): string {
return $this->getRequest()->headers->get('HX-Trigger-Name', '');
}
Return only main content
- Useful when the markup you need will be in the main content.
- On a per-request basis
(new Htmx())->onlyMainContent() - On dedicated routes
test_htmx.attachments.route_option: path: '/htmx-test-attachments/route-option' defaults: _title: 'Using _htmx_route option' _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::replace' requirements: _permission: 'access content' options: _htmx_route: TRUE
Examples
ConfigSingleExportForm
ConfigSingleExportForm: Dynamic selects
ConfigSingleExportForm
namespace Drupal\config\Form;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Config\StorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Serialization\Yaml;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a form for exporting a single configuration file.
*
* @internal
*/
class ConfigSingleExportForm extends FormBase {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The config storage.
*
* @var \Drupal\Core\Config\StorageInterface
*/
protected $configStorage;
/**
* Tracks the valid config entity type definitions.
*
* @var \Drupal\Core\Entity\EntityTypeInterface[]
*/
protected $definitions = [];
/**
* Constructs a new ConfigSingleImportForm.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Config\StorageInterface $config_storage
* The config storage.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, StorageInterface $config_storage) {
$this->entityTypeManager = $entity_type_manager;
$this->configStorage = $config_storage;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
$container->get('config.storage')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'config_single_export_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, string $config_type = '', string $config_name = '') {
foreach ($this->entityTypeManager->getDefinitions() as $entity_type => $definition) {
if ($definition->entityClassImplements(ConfigEntityInterface::class)) {
$this->definitions[$entity_type] = $definition;
}
}
$entity_types = array_map(function (EntityTypeInterface $definition) {
return $definition->getLabel();
}, $this->definitions);
// Sort the entity types by label, then add the simple config to the top.
uasort($entity_types, 'strnatcasecmp');
$config_types = [
'system.simple' => $this->t('Simple configuration'),
] + $entity_types;
$form['config_type'] = [
'#title' => $this->t('Configuration type'),
'#type' => 'select',
'#options' => $config_types,
'#default_value' => $config_type,
];
// The config_name element depends on the value of config_type.
// Select and replace the wrapper element of the %3Cscript%3E tag
$form_url = Url::fromRoute('config.export_single', ['config_type' => $config_type, 'config_name' => $config_name]);
(new Htmx())
->post($form_url)
->onlyMainContent()
->select('*:has(>select[name="config_name"])')
->target('*:has(>select[name="config_name"])')
->swap('outerHTML')
->applyTo($form['config_type']);
$default_type = $form_state->getValue('config_type', $config_type);
$form['config_name'] = [
'#title' => $this->t('Configuration name'),
'#type' => 'select',
'#options' => $this->findConfiguration($default_type),
'#empty_option' => $this->t('- Select -'),
'#default_value' => $config_name,
];
// The export element depends on the value of config_type and config_name.
// Select and replace the wrapper element of the export textarea.
(new Htmx())
->post($form_url)
->onlyMainContent()
->select('[data-export-wrapper]')
->target('[data-export-wrapper]')
->swap('outerHTML')
->applyTo($form['config_name']);
$form['export'] = [
'#title' => $this->t('Here is your configuration:'),
'#type' => 'textarea',
'#rows' => 24,
'#wrapper_attributes' => [
'data-export-wrapper' => TRUE,
],
];
$pushUrl = FALSE;
$trigger = $this->getHtmxTriggerName();
if ($trigger == 'config_type') {
$form = $this->updateConfigurationType($form, $form_state);
// Also update the empty export element "out of band".
(new Htmx())
->swapOob('outerHTML:[data-export-wrapper]')
->applyTo($form['export'], '#wrapper_attributes');
$pushUrl = Url::fromRoute('config.export_single', ['config_type' => $default_type, 'config_name' => '']);
}
elseif ($trigger == 'config_name') {
// A name is selected.
$default_name = $form_state->getValue('config_name', $config_name);
$form['export'] = $this->updateExport($form, $default_type, $default_name);
// Update the url in the browser location bar.
$pushUrl = Url::fromRoute('config.export_single', ['config_type' => $default_type, 'config_name' => $default_name]);
}
elseif ($config_type && $config_name) {
// We started with values, update the export using those.
$form['export'] = $this->updateExport($form, $config_type, $config_name);
}
if ($pushUrl) {
(new Htmx())
->pushUrlHeader($pushUrl)
->applyTo($form);
}
return $form;
}
/**
* Handles switching the configuration type selector.
*/
public function updateConfigurationType($form, FormStateInterface $form_state) {
$form['config_name']['#options'] = $this->findConfiguration($form_state->getValue('config_type'));
$form['export']['#value'] = NULL;
return $form;
}
/**
* Handles switching the export textarea.
*/
public function updateExport($form, string $config_type, string $config_name) {
// Determine the full config name for the selected config entity.
// Calling this in the main form build requires accounting for not yet
// having input.
if (!empty($config_type) && $config_type !== 'system.simple') {
$definition = $this->entityTypeManager->getDefinition($config_type);
$name = $definition->getConfigPrefix() . '.' . $config_name;
}
// The config name is used directly for simple configuration.
else {
$name = $config_name;
}
// Read the raw data for this config name, encode it, and display it.
$exists = $this->configStorage->exists($name);
$form['export']['#value'] = !$exists ? NULL : Yaml::encode($this->configStorage->read($name));
$form['export']['#description'] = !$exists ? NULL : $this->t('Filename: %name', ['%name' => $name . '.yml']);
return $form['export'];
}
/**
* Handles switching the configuration type selector.
*/
protected function findConfiguration($config_type) {
$names = [];
// For a given entity type, load all entities.
if ($config_type && $config_type !== 'system.simple') {
$entity_storage = $this->entityTypeManager->getStorage($config_type);
foreach ($entity_storage->loadMultiple() as $entity) {
$entity_id = $entity->id();
if ($label = $entity->label()) {
$names[$entity_id] = new TranslatableMarkup('@id (@label)', ['@label' => $label, '@id' => $entity_id]);
}
else {
$names[$entity_id] = $entity_id;
}
}
}
// Handle simple configuration.
else {
// Gather the config entity prefixes.
$config_prefixes = array_map(function (EntityTypeInterface $definition) {
return $definition->getConfigPrefix() . '.';
}, $this->definitions);
// Find all config, and then filter our anything matching a config prefix.
$names += $this->configStorage->listAll();
$names = array_combine($names, $names);
foreach ($names as $config_name) {
foreach ($config_prefixes as $config_prefix) {
if (str_starts_with($config_name, $config_prefix)) {
unset($names[$config_name]);
}
}
}
}
return $names;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Nothing to submit.
}
}
Token Browser
$htmx = new Htmx();
$htmx->get(Url::fromRoute('token_browser.htmx', [
'token_type' => $token_info['parent_top'],
'parent' => $token_info['parent'],
'token' => $token,
]))
->trigger('click')
->swap('outerHTML')
->target('closest tr');
$htmx->applyTo($wrapper['data']['button']);
$row['data-hx-select'] = 'tr';
https://www.drupal.org/project/token_browser
Display Builder
A display building tool for ambitious site builders built with HTMX
- Design system native: fully use your design system (components, style utilities, icons, themes/modes, CSS variables...) directly in Drupal
- Unified: replace Layout Builder for entity view displays, Block Layout for page displays, and as a replacement of the Views' display building feature.
- Modern: powerful features (dynamic previews, presets, deep integration with Drupal APIs...) without leveraging Drupal libraries
ECA
Key user interactions shown in the Driesnote built with HTMX

Let's Build Something
Dynamically insert the login form so that a user can login without loading a new page.
Find the route for /user/login
user.login:
path: '/user/login'
defaults:
_form: '\Drupal\user\Form\UserLoginForm'
_title: 'Log in'
requirements:
_user_is_logged_in: 'FALSE'
options:
_maintenance_access: TRUE
core/modules/user/user.routing.yml
Inspect the source page and find the CSS selector of the element that you want to select
Inspect the page you are building and find the CSS selector of your target element
Also decide on placement in relation to the target element
Decision Summary
- Where can you request the markup?
- What is the CSS selector for the markup you want?
- What is the CSS selector for the target of your swap?
- What is your swap strategy in relationship to your target?
Build the render array
public function build(): array {
$build['login'] = [
'#type' => 'button',
'#value' => new TranslatableMarkup('Login'),
'#submit_button' => FALSE,
"#access" => $this->currentUser->isAnonymous(),
];
$url = Url::fromRoute('user.login');
(new Htmx())->get($url)
->onlyMainContent()
->select('#user-login-form')
->target('#header-nav')
->swap('afterend')
->applyTo($build['login']);
// Keep a logout link.
$build['logout'] = [
'#type' => 'link',
'#title' => new TranslatableMarkup('Logout'),
'#url' => Url::fromRoute('user.logout'),
"#access" => $this->currentUser->isAuthenticated(),
];
$cache = new CacheableMetadata();
$cache->addCacheContexts(['user.roles']);
$cache->applyTo($build);
return $build;
}
The Initiative:
Replace AJAX API with HTMX
Lead Collaborators
- Théodore Biadala (nod_)
Frontend Framework Manager - Nathaniel Catchpole (catch)
Release manager - Shawn Duncan (fathershawn)
Subsystem maintainer and initiative coordinator - Pierre Dureau (pdureau)
provisional Frontend Framework Manager
The Work
Refactor every use of the existing jQuery based Ajax API to use HTMX instead.
Join in!
- Join the #htmx channel in Drupal Slack.
- Research and write up a replacement strategy for our Cookook.
- Find other collaborators in the Contribution Room tomorrow.
Reference links
Thanks for coming!
Session evaluation
See the slides at https://github.com/
FatherShawn/htmx-talk