SuperAPI AI Directory

drupal-htmx

Frontend & websites

View repository

Updated 10-16 02:14

About this skill

Drupal HTMX

HTMX 2.0.4 ships in Drupal 11.3+ core as core/htmx. No contrib module needed.

When to use HTMX vs AJAX Form API

Scenario Use
Form fields that update other fields HTMX (swapOob)
Simple form submit with partial page update HTMX or AJAX Form API
Complex multi-step AJAX with AjaxResponse commands AJAX Form API
Non-form UI interactions (lazy load, infinite scroll, boost) HTMX
Existing forms you're modifying AJAX Form API (less disruption)

Attaching the Library

# mymodule.libraries.yml
mymodule/htmx-feature:
  dependencies:
    - core/drupal.htmx

core/drupal.htmx includes: htmx vendor JS + Drupal behaviors bridge + asset loader + drupalSettings integration.

The Htmx PHP Fluent Builder

use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Url;

$htmx = new Htmx();
$htmx->post($url)->target('#my-wrapper')->swap('outerHTML');
$htmx->applyTo($element);

applyTo() writes data-hx-* attributes directly onto the render array element. Pass '#wrapper_attributes' as second argument to target the wrapper div instead.

$htmx->applyTo($form['field'], '#wrapper_attributes');

Request Attributes

$htmx->get(?Url $url)          // data-hx-get
$htmx->post(?Url $url)         // data-hx-post
$htmx->put(?Url $url)
$htmx->patch(?Url $url)
$htmx->delete(?Url $url)
$htmx->target('#selector')     // data-hx-target
$htmx->swap('outerHTML')       // data-hx-swap: innerHTML|outerHTML|beforebegin|afterbegin|beforeend|afterend|delete|none
$htmx->swapOob('true')         // data-hx-swap-oob: out-of-band swap
$htmx->select('css selector')  // data-hx-select: pick part of response
$htmx->selectOob(['#id:outerHTML']) // data-hx-select-oob
$htmx->trigger('click')        // data-hx-trigger
$htmx->vals(['key' => 'val'])  // data-hx-vals: extra POST values
$htmx->boost()                 // data-hx-boost: SPA-like link/form enhancement
$htmx->confirm('Are you sure?')
$htmx->on('click', 'alert()')  // data-hx-on:click
$htmx->pushUrl(true)           // data-hx-push-url
$htmx->onlyMainContent()       // adds data-hx-drupal-only-main-content (triggers HtmxRenderer)

Response Headers (applied to render array, sent with response)

$htmx->pushUrlHeader($url)                  // HX-Push-Url
$htmx->redirectHeader($url)                 // HX-Redirect
$htmx->triggerHeader('myEvent')             // HX-Trigger (also accepts array with data)
$htmx->triggerAfterSettleHeader('myEvent')  // HX-Trigger-After-Settle
$htmx->refreshHeader(true)                  // HX-Refresh
// Also: replaceUrlHeader, locationHeader, reswapHeader, retargetHeader, reselectHeader, triggerAfterSwapHeader

Pattern: Dependent Selects (Dynamic Forms)

A select that updates another select without page reload. Each field posts to <current> with swapOob so both fields re-render out-of-band.

public function buildForm(array $form, FormStateInterface $form_state, string $type = '', string $value = ''): array {
  $formUrl = Url::fromRoute('<current>');

  $form['type'] = [
    '#type' => 'select',
    '#options' => ['a' => 'A', 'b' => 'B'],
    '#default_value' => $type,
  ];
  (new Htmx())->post($formUrl)->swap('none')->swapOob('true')->applyTo($form['type']);

  $form['value'] = [
    '#type' => 'select',
    '#options' => $this->getOptionsFor($form_state->getValue('type', $type)),
    '#default_value' => $value,
  ];
  (new Htmx())->post($formUrl)->swap('none')->swapOob('true')->applyTo($form['value']);

  // Optionally push URL on change:
  $trigger = $form_state->getUserInput()['_triggering_element_name'] ?? FALSE;
  if ($trigger) {
    (new Htmx())->pushUrlHeader(Url::fromRoute('mymodule.form', [...]))->applyTo($form[$trigger]);
  }

  return $form;
}

public function submitForm(array &$form, FormStateInterface $form_state): void {}

Pattern: Partial Route (_htmx_route)

A route that returns only its content fragment (no <html> shell). Use for controllers responding to HTMX hx-get/hx-post calls.

# mymodule.routing.yml
mymodule.htmx_partial:
  path: '/htmx/content/{id}'
  defaults:
    _controller: '\Drupal\mymodule\Controller\HtmxController::content'
    _title: 'Content'
  options:
    _htmx_route: true   # Uses HtmxRenderer — returns HTML fragment only
  requirements:
    _permission: 'access content'
    id: '\d+'
// Controller returns a render array normally — HtmxRenderer handles the rest.
public function content(int $id): array {
  $node = $this->entityTypeManager->getStorage('node')->load($id);
  return [
    '#theme' => 'mymodule_content_card',
    '#node' => $node,
    '#cache' => ['tags' => $node->getCacheTags()],
  ];
}

Pattern: onlyMainContent() — Drupal Layout Bypass

Add data-hx-drupal-only-main-content to any HTMX element. The Drupal JS bridge automatically appends ?_wrapper_format=drupal_htmx to the request, triggering HtmxRenderer (no page chrome, no blocks, no regions).

$htmx = new Htmx();
$htmx->get($url)->onlyMainContent()->target('#content-area')->applyTo($element);

Alternatively add the attribute directly in Twig:

<a href="{{ path('mymodule.page') }}"
   data-hx-get="{{ path('mymodule.page') }}"
   data-hx-drupal-only-main-content
   data-hx-target="#content-area">
  Load content
</a>

Pattern: Boost (SPA-like Navigation)

Apply hx-boost to a container and all links/forms inside become AJAX-driven, replacing <body> and updating <title>. Drupal's Drupal.behaviors re-attaches automatically on each swap.

$form['#attributes']['data-hx-boost'] = 'true';
// or via Htmx builder:
(new Htmx())->boost()->applyTo($form);

Pattern: Lazy Loading / Infinite Scroll

{# Load content when element scrolls into view #}
<div data-hx-get="{{ path('mymodule.more_items', {page: page}) }}"
     data-hx-trigger="revealed"
     data-hx-swap="outerHTML">
  <div class="loading-spinner">{{ 'Loading...'|t }}</div>
</div>

Pattern: Confirm Before Action

(new Htmx())
  ->delete(Url::fromRoute('mymodule.delete', ['id' => $id]))
  ->target('#item-' . $id)
  ->swap('outerHTML')
  ->confirm((string) $this->t('Delete this item?'))
  ->applyTo($form['delete_button']);

Pattern: Trigger Custom JS Event from Response

// In controller or form, trigger a JS event after swap:
(new Htmx())
  ->triggerAfterSettleHeader('mymodule:itemUpdated')
  ->applyTo($element);
// In your Drupal behavior:
Drupal.behaviors.mymoduleListener = {
  attach(context) {
    context.addEventListener('mymodule:itemUpdated', (e) => {
      // React to HTMX-triggered event
    });
  }
};

Drupal.behaviors Integration

No extra setup needed. The core/drupal.htmx library auto-wires:

  • htmx:drupal:load → calls Drupal.attachBehaviors() on swapped content
  • htmx:drupal:unload → calls Drupal.detachBehaviors() before swap

All existing JS behaviors work transparently on HTMX-injected content.

drupalSettings in HTMX Responses

drupalSettings from HTMX responses is deep-merged into the current page's drupalSettings automatically by htmx-utils.js. No action needed.

Detecting HTMX Requests in PHP

use Symfony\Component\HttpFoundation\Request;

// Check if request is from HTMX
$isHtmx = $request->headers->has('HX-Request');
$trigger = $request->headers->get('HX-Trigger-Name'); // triggering element name
$currentUrl = $request->headers->get('HX-Current-URL');

Cache Considerations

HTMX partial responses go through Drupal's render cache normally. Apply cache metadata as usual:

return [
  '#theme' => 'mymodule_partial',
  '#cache' => [
    'tags' => ['node:' . $node->id()],
    'contexts' => ['url.query_args'],
    'max-age' => 0, // for frequently-changing content
  ],
];

HtmxRenderer adds the rendered cache tag and required cache contexts automatically.

Twig — Attaching HTMX Attributes

{# Attach htmx library #}
{{ attach_library('mymodule/htmx-feature') }}

{# Render an element with htmx attributes (set via PHP Htmx builder) #}
{{ content.my_field }}

{# Or write attributes directly in Twig #}
<button data-hx-post="{{ path('mymodule.action') }}"
        data-hx-target="#result"
        data-hx-swap="innerHTML">
  {{ 'Submit'|t }}
</button>

Always use data-hx-* (not bare hx-*) — Drupal's implementation uses the data- prefix convention.

Routing for Forms with HTMX

Route parameters from the URL are passed to buildForm() as extra arguments:

mymodule.form:
  path: '/mymodule/form/{type}/{value}'
  defaults:
    _form: '\Drupal\mymodule\Form\DependentSelectForm'
    _title: 'My Form'
    type: ''
    value: ''
  requirements:
    _permission: 'access content'
public function buildForm(array $form, FormStateInterface $form_state, string $type = '', string $value = ''): array {

Common Mistakes

Missing library: HTMX attributes render as static HTML without core/drupal.htmx attached. Always attach in libraries.yml or #attached.

Using bare hx-*: Drupal uses data-hx-*. The Htmx builder adds the data- prefix automatically. In Twig, write data-hx-get not hx-get.

Forgetting swap('none') on oob triggers: When using swapOob, the trigger element itself also gets swapped unless you set swap('none').

CSRF on POST: Drupal's Form API handles CSRF tokens. For non-form HTMX POSTs to controllers, add the route requirement _csrf_token: 'TRUE' and append ?token={{ drupal_csrf_token(...) }} to the URL.

Raw markup for target placeholders: Never use #markup to render a standalone HTMX target div — it bypasses Drupal's render pipeline. Use #type => 'html_tag' with #attributes instead:

// Bad
'target' => ['#markup' => '<div id="greeting-target"></div>'],

// Good
'target' => [
  '#type' => 'html_tag',
  '#tag' => 'div',
  '#value' => '',
  '#attributes' => ['id' => 'greeting-target'],
],

If the target can be the wrapper of an existing render element, skip the separate div and use applyTo() with '#wrapper_attributes' plus an #attributes['id'] on that element instead.