agentleFS
edutruledutrul/drupal-ai/.claude/skills/drupal-htmx/SKILL.md

drupal-htmx

Skill71 starsChanged 7 months agoMITView on GitHub

HTMX in Drupal 11.3+ core — the Htmx PHP fluent builder, dynamic forms with swapOob, partial routes with _htmx_route, response headers, and Drupal.behaviors integration. Use when building interactive UI without full-page reloads using Drupal's native HTMX support.

The file

We split this skill into the rules it gives an agent

7 rules. About 0 are common, 1 is distinctive, and 6 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 6

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Add data-hx-drupal-only-main-content to any HTMX element.

    line 147
  2. Very specific:

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

    line 219
  3. Distinctive:

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

    line 224
  4. Very specific:

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

    line 275
  5. Very specific:

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

    line 303
  6. Very specific:

    Raw markup for target placeholders: Never use #markup to render a standalone HTMX target div — it bypasses Drupal's render pipeline.

    line 307
  7. Very specific:

    If the target can be the wrapper of an existing render element, skip the separate div and use applyTo() with '#wrapper_attributes' plus an …

    line 322

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

ScenarioUse
Form fields that update other fieldsHTMX (swapOob)
Simple form submit with partial page updateHTMX or AJAX Form API
Complex multi-step AJAX with AjaxResponse commandsAJAX Form API
Non-form UI interactions (lazy load, infinite scroll, boost)HTMX
Existing forms you're modifyingAJAX 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.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.