htmx-development
Use when developing HTMX features in Drupal 11.3+ or migrating AJAX to HTMX. Covers Htmx class usage, form patterns, migration strategies, and validation. Triggers on "htmx", "ajax to htmx", "dynamic form", "dependent dropdown".
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install camoa-claude-skills-htmx-development
Repository
Skill path: drupal-htmx/skills/htmx-development
Use when developing HTMX features in Drupal 11.3+ or migrating AJAX to HTMX. Covers Htmx class usage, form patterns, migration strategies, and validation. Triggers on "htmx", "ajax to htmx", "dynamic form", "dependent dropdown".
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: camoa.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install htmx-development into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/camoa/claude-skills before adding htmx-development to shared team environments
- Use htmx-development for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: htmx-development
description: Use when developing HTMX features in Drupal 11.3+ or migrating AJAX to HTMX. Covers Htmx class usage, form patterns, migration strategies, and validation. Triggers on "htmx", "ajax to htmx", "dynamic form", "dependent dropdown".
---
# HTMX Development
Drupal 11.3+ HTMX implementation and AJAX migration guidance.
## When to Use
- Implementing dynamic content updates in Drupal
- Building forms with dependent fields
- Migrating existing AJAX to HTMX
- Adding infinite scroll, load more, real-time validation
- NOT for: Traditional AJAX maintenance (use ajax-reference.md)
## Decision: HTMX vs AJAX
| Choose HTMX | Choose AJAX |
|-------------|-------------|
| New features | Existing AJAX code |
| Declarative HTML preferred | Complex command sequences |
| Returns HTML fragments | Dialog commands needed |
| Progressive enhancement needed | Contrib expects AJAX |
**Hybrid OK**: Both systems coexist. Migrate incrementally.
## Quick Start
### 1. Basic HTMX Element
```php
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Url;
$build['button'] = [
'#type' => 'button',
'#value' => t('Load'),
];
(new Htmx())
->get(Url::fromRoute('my.content'))
->onlyMainContent()
->target('#result')
->swap('innerHTML')
->applyTo($build['button']);
```
### 2. Controller Returns Render Array
```php
public function content() {
return ['#markup' => '<p>Content loaded</p>'];
}
```
### 3. Route (Optional HTMX-Only)
```yaml
my.content:
path: '/my/content'
options:
_htmx_route: TRUE # Always minimal response
```
## Core Patterns
### Pattern Selection
| Use Case | Pattern | Key Methods |
|----------|---------|-------------|
| Dependent dropdown | Form partial update | `select()`, `target()`, `swap('outerHTML')` |
| Load more | Append content | `swap('beforeend')`, `trigger('click')` |
| Infinite scroll | Auto-load | `swap('beforeend')`, `trigger('revealed')` |
| Real-time validation | Blur check | `trigger('focusout')`, field update |
| Multi-step wizard | URL-based steps | `pushUrl()`, route parameters |
| Multiple updates | OOB swap | `swapOob('outerHTML:#selector')` |
### Dependent Dropdown
```php
public function buildForm(array $form, FormStateInterface $form_state) {
$form['category'] = ['#type' => 'select', '#options' => $this->getCategories()];
(new Htmx())
->post(Url::fromRoute('<current>'))
->onlyMainContent()
->select('#edit-subcategory--wrapper')
->target('#edit-subcategory--wrapper')
->swap('outerHTML')
->applyTo($form['category']);
$form['subcategory'] = ['#type' => 'select', '#options' => []];
// Handle trigger
if ($this->getHtmxTriggerName() === 'category') {
$form['subcategory']['#options'] = $this->getSubcategories(
$form_state->getValue('category')
);
}
return $form;
}
```
Reference: `core/modules/config/src/Form/ConfigSingleExportForm.php`
### Multiple Element Updates
```php
// Primary element updates via target
// Secondary element updates via OOB
(new Htmx())
->swapOob('outerHTML:[data-secondary]')
->applyTo($form['secondary'], '#wrapper_attributes');
```
### URL History
```php
(new Htmx())
->pushUrlHeader(Url::fromRoute('my.route', $params))
->applyTo($form);
```
## Htmx Class Reference
### Request Methods
- `get(Url)` / `post(Url)` / `put(Url)` / `patch(Url)` / `delete(Url)`
### Control Methods
- `target(selector)` - Where to swap
- `select(selector)` - What to extract from response
- `swap(strategy)` - How to swap (outerHTML, innerHTML, beforeend, etc.)
- `swapOob(selector)` - Out-of-band updates
- `trigger(event)` - When to trigger
- `vals(array)` - Additional values
- `onlyMainContent()` - Minimal response
### Response Headers
- `pushUrlHeader(Url)` - Update browser URL
- `redirectHeader(Url)` - Full redirect
- `triggerHeader(event)` - Fire client event
- `reswapHeader(strategy)` - Change swap
- `retargetHeader(selector)` - Change target
See: `references/quick-reference.md` for complete tables
## Detecting HTMX Requests
In forms (trait included automatically):
```php
if ($this->isHtmxRequest()) {
$trigger = $this->getHtmxTriggerName();
}
```
In controllers (add trait):
```php
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
class MyController extends ControllerBase {
use HtmxRequestInfoTrait;
protected function getRequest() { return \Drupal::request(); }
}
```
## Migration from AJAX
### Quick Conversion
| AJAX | HTMX |
|------|------|
| `'#ajax' => ['callback' => '::cb']` | `(new Htmx())->post()->applyTo()` |
| `'wrapper' => 'id'` | `->target('#id')` |
| `return $form['element']` | Logic in `buildForm()` |
| `new AjaxResponse()` | Return render array |
| `ReplaceCommand` | `->swap('outerHTML')` |
| `HtmlCommand` | `->swap('innerHTML')` |
| `AppendCommand` | `->swap('beforeend')` |
| `MessageCommand` | Auto-included |
### Migration Steps
1. Identify `#ajax` properties
2. Replace with `Htmx` class
3. Move callback logic to `buildForm()`
4. Use `getHtmxTriggerName()` for conditional logic
5. Replace `AjaxResponse` with render arrays
6. Test progressive enhancement
See: `references/migration-patterns.md` for detailed examples
## Validation Checklist
When reviewing HTMX implementations:
- [ ] `Htmx` class used (not raw attributes)
- [ ] `onlyMainContent()` for minimal response
- [ ] Proper swap strategy selected
- [ ] OOB used for multiple updates
- [ ] Trigger element detection works
- [ ] Works without JavaScript (progressive)
- [ ] Accessibility: `aria-live` for dynamic regions
- [ ] URL updates for bookmarkable states
## Common Issues
| Problem | Solution |
|---------|----------|
| Content not swapping | Check `target()` selector exists |
| Wrong content extracted | Check `select()` selector |
| JS not running | Verify `htmx:drupal:load` fires |
| Form not submitting | Check `post()` and URL |
| Multiple swaps fail | Add `swapOob('true')` to elements |
| History broken | Use `pushUrlHeader()` |
## References
- `references/quick-reference.md` - Command equivalents, method tables
- `references/htmx-implementation.md` - Full Htmx class API, detection, JS
- `references/migration-patterns.md` - 7 patterns with before/after code
- `references/ajax-reference.md` - AJAX commands for understanding existing code
## Key Files in Drupal Core
- `core/lib/Drupal/Core/Htmx/Htmx.php` - Main API
- `core/lib/Drupal/Core/Htmx/HtmxRequestInfoTrait.php` - Request detection
- `core/lib/Drupal/Core/Render/MainContent/HtmxRenderer.php` - Response renderer
- `core/modules/config/src/Form/ConfigSingleExportForm.php` - Production example
- `core/modules/system/tests/modules/test_htmx/` - Test examples
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### references/quick-reference.md
```markdown
# Quick Reference
## AJAX to HTMX Command Equivalents
| AJAX Command | HTMX Equivalent | Notes |
|--------------|-----------------|-------|
| `ReplaceCommand` | `swap('outerHTML')` | Replace entire element |
| `HtmlCommand` | `swap('innerHTML')` | Replace inner content |
| `AppendCommand` | `swap('beforeend')` | Append inside element |
| `PrependCommand` | `swap('afterbegin')` | Prepend inside element |
| `BeforeCommand` | `swap('beforebegin')` | Insert before element |
| `AfterCommand` | `swap('afterend')` | Insert after element |
| `RemoveCommand` | Empty + `swap('outerHTML')` | Remove element |
| `RedirectCommand` | `redirectHeader(Url)` | Full page redirect |
| `MessageCommand` | Auto-included | Via HtmxRenderer |
| `SettingsCommand` | Auto-merged | Via htmx-assets.js |
| Multiple commands | `swapOob()` | Out-of-band swaps |
## Htmx Class Methods
### Request Methods
| Method | Result |
|--------|--------|
| `get(Url)` | `data-hx-get` |
| `post(Url)` | `data-hx-post` |
| `put(Url)` | `data-hx-put` |
| `patch(Url)` | `data-hx-patch` |
| `delete(Url)` | `data-hx-delete` |
### Control Methods
| Method | Result | Purpose |
|--------|--------|---------|
| `target(selector)` | `data-hx-target` | Where to swap |
| `select(selector)` | `data-hx-select` | What to extract from response |
| `swap(strategy)` | `data-hx-swap` | How to swap |
| `swapOob(true\|selector)` | `data-hx-swap-oob` | Out-of-band updates |
| `trigger(event)` | `data-hx-trigger` | When to trigger |
| `vals(array)` | `data-hx-vals` | Additional values as JSON |
| `onlyMainContent()` | Adds `_wrapper_format` | Minimal response |
### Response Headers
| Method | Header | Purpose |
|--------|--------|---------|
| `pushUrlHeader(Url)` | `HX-Push-Url` | Update browser URL |
| `redirectHeader(Url)` | `HX-Redirect` | Full page redirect |
| `triggerHeader(event)` | `HX-Trigger` | Fire client event |
| `reswapHeader(strategy)` | `HX-Reswap` | Change swap strategy |
| `retargetHeader(selector)` | `HX-Retarget` | Change target |
## Swap Strategies
| Strategy | Description |
|----------|-------------|
| `innerHTML` | Replace inner content (default) |
| `outerHTML` | Replace entire element |
| `beforebegin` | Insert before element |
| `afterbegin` | Prepend inside element |
| `beforeend` | Append inside element |
| `afterend` | Insert after element |
| `delete` | Remove element |
| `none` | No swap (headers only) |
## Common Patterns
### Dependent Dropdown
```php
(new Htmx())
->post($form_url)
->onlyMainContent()
->select('#subcategory-wrapper')
->target('#subcategory-wrapper')
->swap('outerHTML')
->applyTo($form['category']);
```
### Load More / Infinite Scroll
```php
(new Htmx())
->get(Url::fromRoute('my.items', ['page' => $next]))
->trigger('revealed') // or 'click'
->select('.item-list')
->target('#content-list')
->swap('beforeend')
->applyTo($element);
```
### Multiple Element Updates (OOB)
```php
// Primary target updates normally
// Secondary element uses OOB
(new Htmx())
->swapOob('outerHTML:[data-export-wrapper]')
->applyTo($form['export'], '#wrapper_attributes');
```
### URL History Update
```php
(new Htmx())
->pushUrlHeader(Url::fromRoute('my.route', $params))
->applyTo($form);
```
## Detection Methods (HtmxRequestInfoTrait)
Available in `FormBase` and any class using the trait:
| Method | Returns |
|--------|---------|
| `isHtmxRequest()` | TRUE if HX-Request header present |
| `getHtmxTriggerName()` | Triggering element name |
| `getHtmxTarget()` | Target element ID |
| `getHtmxCurrentUrl()` | Current page URL |
## Route Configuration
```yaml
# Always returns minimal HTML
my_module.htmx_endpoint:
path: '/my-module/htmx-content'
options:
_htmx_route: TRUE
```
## JavaScript Events
| Event | When |
|-------|------|
| `htmx:drupal:load` | After swap + assets loaded |
| `htmx:drupal:unload` | Before content removal |
| `htmx:beforeRequest` | Before AJAX request |
| `htmx:afterSwap` | After content swapped |
## Decision: HTMX vs AJAX
| Use HTMX | Use AJAX |
|----------|----------|
| New features | Maintaining existing AJAX |
| Declarative HTML preferred | Complex command sequences |
| Returns HTML | Heavy JS processing |
| Progressive enhancement important | Contrib expects AJAX callbacks |
```
### references/migration-patterns.md
```markdown
# Migration Patterns
AJAX to HTMX migration patterns with side-by-side comparisons.
## Pattern 1: Dependent Dropdown
### AJAX
```php
$form['category'] = [
'#type' => 'select',
'#ajax' => [
'callback' => '::categoryCallback',
'wrapper' => 'subcategory-wrapper',
],
];
$form['subcategory'] = [
'#prefix' => '<div id="subcategory-wrapper">',
'#suffix' => '</div>',
];
public function categoryCallback(array &$form, FormStateInterface $form_state) {
return $form['subcategory'];
}
```
### HTMX
```php
$form['category'] = ['#type' => 'select'];
(new Htmx())
->post(Url::fromRoute('<current>'))
->onlyMainContent()
->select('#edit-subcategory--wrapper')
->target('#edit-subcategory--wrapper')
->swap('outerHTML')
->applyTo($form['category']);
$form['subcategory'] = ['#type' => 'select'];
// Check trigger in buildForm - no callback needed
$trigger = $this->getHtmxTriggerName();
if ($trigger === 'category') {
$form['subcategory']['#options'] = $this->getSubcategories($form_state->getValue('category'));
}
```
**Key Changes:**
- No `#ajax` property → `Htmx` class
- No callback method → logic in `buildForm()`
- Wrapper ID → CSS selector
- Use `getHtmxTriggerName()` for trigger detection
Reference: `core/modules/config/src/Form/ConfigSingleExportForm.php`
---
## Pattern 2: Cascading Selects with URL Update
### AJAX
```php
$form['type']['#ajax'] = ['callback' => '::updateName', 'wrapper' => 'name-wrapper'];
$form['name']['#ajax'] = ['callback' => '::updateExport', 'wrapper' => 'export-wrapper'];
public function updateName(...) { return $form['name']; }
public function updateExport(...) { return $form['export']; }
```
### HTMX
```php
// Type updates name
(new Htmx())
->post($form_url)
->onlyMainContent()
->select('*:has(>select[name="name"])')
->target('*:has(>select[name="name"])')
->swap('outerHTML')
->applyTo($form['type']);
// Name updates export
(new Htmx())
->post($form_url)
->onlyMainContent()
->select('[data-export-wrapper]')
->target('[data-export-wrapper]')
->swap('outerHTML')
->applyTo($form['name']);
// Handle trigger and URL push
$trigger = $this->getHtmxTriggerName();
if ($trigger === 'type') {
// Also clear export via OOB
(new Htmx())
->swapOob('outerHTML:[data-export-wrapper]')
->applyTo($form['export'], '#wrapper_attributes');
$pushUrl = Url::fromRoute('my.form', ['type' => $form_state->getValue('type')]);
}
if ($pushUrl) {
(new Htmx())->pushUrlHeader($pushUrl)->applyTo($form);
}
```
**Key Changes:**
- Use `swapOob()` for multiple element updates
- Use `pushUrlHeader()` for URL management
- Route parameters enable bookmarkable URLs
---
## Pattern 3: Button-Triggered Content Load
### AJAX
```php
// Controller
public function loadContent() {
$response = new AjaxResponse();
$response->addCommand(new ReplaceCommand('#wrapper', $content));
$response->addCommand(new MessageCommand('Loaded!'));
return $response;
}
// Form
$form['button'] = [
'#type' => 'button',
'#ajax' => ['callback' => '::loadCallback', 'wrapper' => 'wrapper'],
];
```
### HTMX
```php
// Controller - returns render array, not AjaxResponse
public function loadContent() {
return [
'#theme' => 'my_content',
'#data' => $this->getData(),
];
}
// Form
$form['button'] = [
'#type' => 'html_tag',
'#tag' => 'button',
'#value' => t('Load'),
'#attributes' => ['type' => 'button'],
];
(new Htmx())
->get(Url::fromRoute('my.load'))
->onlyMainContent()
->target('#wrapper')
->swap('innerHTML')
->applyTo($form['button']);
```
**Key Changes:**
- Controller returns render array, not `AjaxResponse`
- No `MessageCommand` → auto-included by HtmxRenderer
- `select()` filters response, `target()` specifies destination
---
## Pattern 4: Multi-Step Wizard
### AJAX
```php
$form['next'] = [
'#type' => 'submit',
'#submit' => ['::nextStep'],
'#ajax' => ['callback' => '::stepCallback', 'wrapper' => 'form-wrapper'],
];
public function nextStep(...) {
$form_state->set('step', $step + 1);
$form_state->setRebuild();
}
public function stepCallback(...) { return $form; }
```
### HTMX
```php
public function buildForm(..., $step = 1) {
$nextUrl = Url::fromRoute('my.wizard', ['step' => $step + 1]);
$form['next'] = ['#type' => 'button', '#value' => t('Next')];
(new Htmx())
->post($nextUrl)
->onlyMainContent()
->target('#wizard-form')
->swap('outerHTML')
->pushUrl($nextUrl)
->applyTo($form['next']);
$form['#attributes']['id'] = 'wizard-form';
return $form;
}
```
**Key Changes:**
- Step is route parameter, not form state
- Each step has own URL (bookmarkable, back button works)
- No submit handlers for navigation
---
## Pattern 5: Real-time Validation
### AJAX
```php
$form['email'] = [
'#type' => 'email',
'#ajax' => [
'callback' => '::validateEmail',
'wrapper' => 'email-validation',
'event' => 'focusout',
'progress' => ['type' => 'none'],
],
];
public function validateEmail(...) {
// Build and return validation message
return $form['email_validation'];
}
```
### HTMX
```php
$form['email'] = ['#type' => 'email'];
(new Htmx())
->post(Url::fromRoute('<current>'))
->onlyMainContent()
->trigger('focusout')
->select('#email-validation')
->target('#email-validation')
->swap('outerHTML')
->applyTo($form['email']);
// In buildForm
$trigger = $this->getHtmxTriggerName();
if ($trigger === 'email') {
$email = $form_state->getValue('email');
$form['email_validation']['#markup'] = $this->emailExists($email)
? '<span class="error">Taken</span>'
: '<span class="success">Available</span>';
}
```
---
## Pattern 6: Infinite Scroll / Load More
### AJAX
```php
$form['load_more'] = [
'#ajax' => ['callback' => '::loadMore', 'wrapper' => 'list', 'method' => 'append'],
];
```
### HTMX
```php
// Click trigger
(new Htmx())
->get(Url::fromRoute('my.items', ['page' => $nextPage]))
->onlyMainContent()
->select('.item-list')
->target('#content-list')
->swap('beforeend')
->applyTo($form['load_more']);
// Scroll trigger (sentinel at bottom)
(new Htmx())
->get(Url::fromRoute('my.items', ['page' => $nextPage]))
->trigger('revealed')
->select('.item-list')
->target('#content-list')
->swap('beforeend')
->applyTo($form['sentinel']);
```
---
## Pattern 7: Dynamic Field Addition
### AJAX
```php
$form['add_item'] = [
'#type' => 'submit',
'#submit' => ['::addItem'],
'#ajax' => ['callback' => '::itemsCallback', 'wrapper' => 'items-wrapper'],
];
public function addItem(...) {
$form_state->set('item_count', $count + 1);
$form_state->setRebuild();
}
```
### HTMX
```php
$item_count = $form_state->getValue('item_count', 1);
$form['item_count'] = ['#type' => 'hidden', '#value' => $item_count];
$form['add_item'] = ['#type' => 'button', '#value' => t('Add Item')];
(new Htmx())
->post(Url::fromRoute('<current>'))
->onlyMainContent()
->vals(['item_count' => $item_count + 1])
->select('#items-wrapper')
->target('#items-wrapper')
->swap('outerHTML')
->applyTo($form['add_item']);
```
**Key Changes:**
- Use `vals()` to send incremented count
- Hidden field tracks count instead of form state
- No submit handler needed
---
## JavaScript Migration
### Event Mapping
| AJAX Hook | HTMX Event |
|-----------|------------|
| `beforeSerialize` | `htmx:configRequest` |
| `beforeSubmit` | `htmx:beforeRequest` |
| `success` | `htmx:afterSwap` |
| `error` | `htmx:responseError` |
| After behaviors | `htmx:drupal:load` |
| Before removal | `htmx:drupal:unload` |
### Custom AJAX Command → HTMX Trigger
**AJAX:**
```php
$response->addCommand(new NotificationCommand('Hello!'));
// JS
Drupal.AjaxCommands.prototype.showNotification = function(ajax, response) {
alert(response.message);
};
```
**HTMX:**
```php
(new Htmx())
->triggerHeader(['showNotification' => ['message' => 'Hello!']])
->applyTo($build);
// JS
htmx.on('showNotification', (e) => alert(e.detail.message));
```
---
## When NOT to Migrate
Keep AJAX for:
- Complex command sequences
- `CssCommand`, `InvokeCommand` for jQuery methods
- `OpenModalDialogCommand`, `CloseDialogCommand`
- `DataCommand` for jQuery data API
- Contrib modules expecting AJAX callbacks
### Hybrid Approach
Both systems coexist. AJAX can insert HTMX-enabled content:
```php
public function ajaxCallback(...) {
$build = ['#type' => 'container'];
$build['htmx_button'] = ['#type' => 'button', '#value' => t('Refresh')];
(new Htmx())
->get(Url::fromRoute('my.refresh'))
->target('#wrapper')
->applyTo($build['htmx_button']);
return $build;
}
```
`Drupal.behaviors.htmx` ensures HTMX processes content inserted by AJAX.
---
## Migration Checklist
- [ ] Identify all `#ajax` properties
- [ ] Replace with `Htmx` class configuration
- [ ] Convert callbacks to `buildForm()` logic
- [ ] Replace `AjaxResponse` with render arrays
- [ ] Update routes with `_htmx_route: TRUE` if needed
- [ ] Migrate JS event handlers to HTMX events
- [ ] Test behaviors attach/detach
- [ ] Test browser history (back/forward)
- [ ] Verify accessibility
- [ ] Test with JavaScript disabled
```
### references/htmx-implementation.md
```markdown
# HTMX Implementation Guide
Drupal 11.3+ native HTMX support reference.
## Core Architecture
### Key Files
- `core/lib/Drupal/Core/Htmx/Htmx.php` - Main API (30+ attribute methods, 11 header methods)
- `core/lib/Drupal/Core/Htmx/HtmxRequestInfoTrait.php` - Request detection (8 methods)
- `core/lib/Drupal/Core/Render/MainContent/HtmxRenderer.php` - Minimal HTML renderer
- `core/misc/htmx/htmx-assets.js` - Asset loading, settings merging
- `core/misc/htmx/htmx-behaviors.js` - Drupal behaviors integration
### Request/Response Flow
1. User interacts with HTMX element
2. JS adds `_wrapper_format`, `ajax_page_state`, triggering element
3. Server routes to controller/form, detects HTMX request
4. Controller returns render array with HTMX headers
5. HtmxRenderer creates minimal HTML response
6. JS loads new CSS/JS assets
7. HTMX swaps content
8. Drupal behaviors attach (`htmx:drupal:load`)
## The Htmx Class
### Basic Usage
```php
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Url;
$htmx = new Htmx();
$htmx->post(Url::fromRoute('my.route'))
->onlyMainContent()
->target('#result')
->swap('innerHTML')
->applyTo($element);
```
### Request Attributes
```php
$htmx->get(Url::fromRoute('my.get')); // GET request
$htmx->post(Url::fromRoute('my.post')); // POST request
$htmx->put(Url::fromRoute('my.put')); // PUT request
$htmx->patch(Url::fromRoute('my.patch')); // PATCH request
$htmx->delete(Url::fromRoute('my.del')); // DELETE request
```
### Control Attributes
```php
$htmx->target('#element-id'); // Where to swap content
$htmx->select('.content-class'); // What to extract from response
$htmx->swap('outerHTML'); // How to swap (default ignoreTitle:true)
$htmx->swap('beforeend', 'scroll:bottom'); // With modifiers
$htmx->swapOob('true'); // Mark for out-of-band swap
$htmx->swapOob('outerHTML:#other'); // OOB with selector
$htmx->trigger('click'); // Trigger event
$htmx->trigger(['load', 'revealed']); // Multiple triggers
$htmx->vals(['key' => 'value']); // Additional values as JSON
$htmx->onlyMainContent(); // Request minimal response
```
### Response Headers
```php
$htmx->pushUrlHeader(Url::fromRoute('my.route')); // Push to history
$htmx->replaceUrlHeader($url); // Replace current URL
$htmx->redirectHeader($url); // Full page redirect
$htmx->refreshHeader(true); // Force page refresh
$htmx->reswapHeader('innerHTML'); // Change swap strategy
$htmx->retargetHeader('#new-target'); // Change target
$htmx->reselectHeader('.new-select'); // Change content selector
$htmx->triggerHeader('eventName'); // Fire client event
$htmx->triggerHeader(['event' => ['data' => 'value']]); // With data
$htmx->triggerAfterSwapHeader('event'); // After swap completes
$htmx->triggerAfterSettleHeader('event'); // After settle
```
### Additional Attributes
```php
$htmx->boost(true); // Progressive enhancement
$htmx->confirm('Are you sure?'); // Confirmation dialog
$htmx->indicator('#spinner'); // Loading indicator
$htmx->include('#other-form'); // Include additional elements
$htmx->pushUrl(true); // Push URL attribute
$htmx->preserve(); // Keep element unchanged
$htmx->validate(true); // Validate before submit
$htmx->on('::afterSwap', 'myHandler()'); // Event handler
```
### Applying to Elements
```php
// Apply to #attributes
$htmx->applyTo($form['element']);
// Apply to specific attribute key
$htmx->applyTo($form['element'], '#wrapper_attributes');
// Apply headers to entire form
$htmx->pushUrlHeader($url)->applyTo($form);
```
## Detecting HTMX Requests
### In Forms (HtmxRequestInfoTrait included)
```php
class MyForm extends FormBase {
public function buildForm(array $form, FormStateInterface $form_state) {
if ($this->isHtmxRequest()) {
$trigger = $this->getHtmxTriggerName();
if ($trigger === 'category') {
// Handle category change
}
}
}
}
```
### In Controllers
```php
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
class MyController extends ControllerBase {
use HtmxRequestInfoTrait;
protected function getRequest() {
return \Drupal::request();
}
public function content() {
if ($this->isHtmxRequest()) {
// Return partial content
}
}
}
```
### Available Methods
| Method | Purpose |
|--------|---------|
| `isHtmxRequest()` | TRUE if HX-Request header present |
| `isHtmxBoosted()` | TRUE if HX-Boosted header present |
| `getHtmxCurrentUrl()` | Current page URL |
| `isHtmxHistoryRestoration()` | History restore request |
| `getHtmxPrompt()` | Prompt value if set |
| `getHtmxTarget()` | Target element ID |
| `getHtmxTrigger()` | Triggering element ID |
| `getHtmxTriggerName()` | Triggering element name |
## Route Configuration
### HTMX-Only Route
```yaml
my_module.htmx_content:
path: '/my-module/htmx-content'
defaults:
_controller: '\Drupal\my_module\Controller\MyController::content'
options:
_htmx_route: TRUE
```
### Dual-Purpose Route
```php
// Use onlyMainContent() instead - adds ?_wrapper_format=drupal_htmx
$htmx->onlyMainContent()->applyTo($element);
```
## Form Patterns
### Dependent Dropdowns
Reference: `core/modules/config/src/Form/ConfigSingleExportForm.php`
```php
public function buildForm(array $form, FormStateInterface $form_state) {
$form_url = Url::fromRoute('<current>');
$form['category'] = [
'#type' => 'select',
'#options' => $this->getCategories(),
];
(new Htmx())
->post($form_url)
->onlyMainContent()
->select('#edit-subcategory--wrapper')
->target('#edit-subcategory--wrapper')
->swap('outerHTML')
->applyTo($form['category']);
$form['subcategory'] = [
'#type' => 'select',
'#options' => $this->getSubcategories($form_state->getValue('category')),
];
return $form;
}
```
### OOB Updates for Multiple Elements
```php
// When category changes, also clear export
$trigger = $this->getHtmxTriggerName();
if ($trigger === 'category') {
(new Htmx())
->swapOob('outerHTML:[data-export-wrapper]')
->applyTo($form['export'], '#wrapper_attributes');
}
```
### Auto form_build_id Updates
FormBuilder automatically updates form_build_id via OOB swap during HTMX requests. No action needed.
Reference: `core/lib/Drupal/Core/Form/FormBuilder.php:782-790`
## JavaScript Integration
### Drupal Behaviors
```javascript
Drupal.behaviors.myBehavior = {
attach(context, settings) {
// context = HTMX-swapped element
},
detach(context, settings, trigger) {
// trigger = 'unload' for HTMX removals
}
};
```
### HTMX Events
```javascript
htmx.on('htmx:beforeRequest', (e) => console.log('Request', e.detail));
htmx.on('htmx:afterSwap', (e) => console.log('Swapped', e.detail));
htmx.on('htmx:drupal:load', (e) => console.log('Behaviors attached'));
```
### Triggering Custom Events
```php
// Server-side
$htmx->triggerHeader(['showNotification' => ['msg' => 'Saved!']]);
// Client-side
htmx.on('showNotification', (e) => alert(e.detail.msg));
```
## Best Practices
### Progressive Enhancement
- Forms should POST normally without JavaScript
- Use semantic HTML, add HTMX attributes
- Test with JavaScript disabled
### Performance
- Use `onlyMainContent()` for minimal responses
- Drupal sends only new CSS/JS (via ajax_page_state)
- Configure proper cache contexts on render arrays
### Accessibility
- Status messages auto-included via HtmxRenderer
- Add `aria-live` regions for dynamic content
- Use `Drupal.announce()` via trigger headers
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Attributes not working | Verify `core/drupal.htmx` library attached |
| Content not swapping | Check target/select selectors exist |
| JS not executing | Ensure behaviors implement `attach` |
| Form not submitting | Verify `post()` and form URL routing |
| Multiple swaps failing | Use `swapOob('true')` on response elements |
| History not updating | Use `pushUrlHeader()` |
```
### references/ajax-reference.md
```markdown
# AJAX Reference
Drupal AJAX system reference for understanding existing patterns before migration.
## AJAX Configuration
### Form Element AJAX Settings
```php
$form['element'] = [
'#type' => 'select',
'#ajax' => [
'callback' => '::ajaxCallback', // Required: PHP callback
'wrapper' => 'target-wrapper', // Target element ID
'method' => 'replaceWith', // jQuery method
'effect' => 'fade', // Animation
'event' => 'change', // Trigger event
'progress' => [
'type' => 'throbber', // throbber, bar, fullscreen
'message' => t('Loading...'),
],
],
];
```
### Common #ajax Properties
| Property | Purpose | Values |
|----------|---------|--------|
| `callback` | PHP method | `'::method'` or `[class, 'method']` |
| `wrapper` | Target ID | HTML ID without `#` |
| `method` | Insert method | `replaceWith`, `html`, `append`, `prepend`, `before`, `after` |
| `event` | Trigger event | `change`, `click`, `keyup`, `focusout` |
| `effect` | Animation | `fade`, `slide`, `none` |
| `progress` | Indicator | `['type' => 'throbber']` |
## AJAX Commands
### Content Manipulation
| Command | Purpose | HTMX Equivalent |
|---------|---------|-----------------|
| `ReplaceCommand('#sel', $content)` | Replace element | `swap('outerHTML')` |
| `HtmlCommand('#sel', $content)` | Replace inner HTML | `swap('innerHTML')` |
| `AppendCommand('#sel', $content)` | Append inside | `swap('beforeend')` |
| `PrependCommand('#sel', $content)` | Prepend inside | `swap('afterbegin')` |
| `BeforeCommand('#sel', $content)` | Insert before | `swap('beforebegin')` |
| `AfterCommand('#sel', $content)` | Insert after | `swap('afterend')` |
| `RemoveCommand('#sel')` | Remove element | Empty + `swap('outerHTML')` |
| `InsertCommand('#sel', $content)` | Generic insert | Various swaps |
### CSS and Styling
| Command | Purpose | Notes |
|---------|---------|-------|
| `CssCommand('#sel', ['prop' => 'val'])` | Set CSS | No direct HTMX equivalent |
| `AddCssCommand($library)` | Add CSS | Auto-handled by HTMX |
| `InvokeCommand('#sel', 'method', $args)` | jQuery method | Use JS events |
### Dialog Commands
| Command | Purpose |
|---------|---------|
| `OpenModalDialogCommand($title, $content, $opts)` | Open modal |
| `OpenDialogCommand('#sel', $title, $content)` | Open dialog |
| `OpenOffCanvasDialogCommand($title, $content)` | Slide-in panel |
| `CloseModalDialogCommand()` | Close modal |
| `CloseDialogCommand('#sel')` | Close specific dialog |
| `SetDialogOptionCommand('#sel', 'option', $val)` | Modify dialog |
| `SetDialogTitleCommand('#sel', $title)` | Update title |
### Feedback Commands
| Command | Purpose | HTMX Equivalent |
|---------|---------|-----------------|
| `MessageCommand($msg, null, ['type' => 'status'])` | Status message | Auto-included |
| `AnnounceCommand($text, 'polite')` | Screen reader | `triggerHeader()` + JS |
| `AlertCommand($msg)` | Browser alert | `triggerHeader()` + JS |
| `ScrollTopCommand('#sel')` | Scroll to element | CSS/JS |
### Navigation Commands
| Command | Purpose | HTMX Equivalent |
|---------|---------|-----------------|
| `RedirectCommand($url)` | Redirect | `redirectHeader(Url)` |
### State Commands
| Command | Purpose |
|---------|---------|
| `ChangedCommand('#el', '#asterisk')` | Mark changed |
| `UpdateBuildIdCommand($old, $new)` | Update form token |
| `RestripeCommand('#table')` | Re-stripe table |
| `SettingsCommand($settings, true)` | Update drupalSettings |
| `FocusFirstCommand('#wrapper')` | Focus first tabbable |
## Callback Patterns
### Return Render Array
```php
public function callback(array &$form, FormStateInterface $form_state) {
return $form['subcategory'];
}
```
### Return AjaxResponse
```php
public function callback(array &$form, FormStateInterface $form_state) {
$response = new AjaxResponse();
$response->addCommand(new ReplaceCommand('#target', $content));
$response->addCommand(new MessageCommand('Updated!'));
return $response;
}
```
### Handling Errors
```php
public function callback(array &$form, FormStateInterface $form_state) {
if ($form_state->hasAnyErrors()) {
return $form; // Return entire form to show errors
}
return $form['result'];
}
```
## Security
### CSRF Protection
Drupal automatically handles AJAX CSRF via `X-Drupal-Ajax-Token` header.
### Route-Level Access
```yaml
my_module.ajax:
path: '/ajax/endpoint'
requirements:
_permission: 'access content'
_csrf_token: 'TRUE'
```
### Callback Access Check
```php
public function callback(...) {
if (!$this->currentUser->hasPermission('edit content')) {
$response = new AjaxResponse();
$response->addCommand(new AlertCommand('Access denied'));
return $response;
}
}
```
### Input Sanitization
```php
$sanitized = Html::escape($form_state->getValue('field'));
```
## JavaScript API
### Creating AJAX Instance
```javascript
var ajax = Drupal.ajax({
url: '/my/endpoint',
event: 'click',
selector: '#button',
wrapper: 'result',
submit: { extraKey: 'value' },
});
ajax.execute();
```
### Lifecycle Hooks
```javascript
var ajax = Drupal.ajax['element-id'];
ajax.beforeSerialize = function(element, options) { /* ... */ };
ajax.beforeSubmit = function(formValues, element, options) { /* ... */ };
ajax.success = function(response, status) { /* ... */ };
ajax.error = function(xhr, uri, message) { /* ... */ };
```
### Custom Command Handler
```javascript
Drupal.AjaxCommands.prototype.myCommand = function(ajax, response, status) {
// response contains data from command's render() method
console.log(response.data);
};
```
## Detecting AJAX Requests
### In Controller
```php
if ($request->isXmlHttpRequest()) {
return new AjaxResponse();
}
```
### Using AjaxHelperTrait
```php
use Drupal\Core\Ajax\AjaxHelperTrait;
class MyController extends ControllerBase {
use AjaxHelperTrait;
public function content() {
if ($this->isAjax()) {
return new AjaxResponse();
}
}
}
```
## Common Patterns to Migrate
### Pattern Recognition
| If You See | It's | Migration |
|------------|------|-----------|
| `'#ajax' => [...]` | Form AJAX | Use `Htmx` class |
| `new AjaxResponse()` | Command response | Return render array |
| `::callback` method | AJAX callback | Logic in `buildForm()` |
| `ReplaceCommand` | Content replace | `swap('outerHTML')` |
| `HtmlCommand` | Inner HTML | `swap('innerHTML')` |
| `AppendCommand` | Append | `swap('beforeend')` |
| `MessageCommand` | Status message | Auto-included |
| `'wrapper' => 'id'` | Target ID | `target('#id')` |
| `'event' => 'change'` | Trigger | Default for select |
| `'event' => 'focusout'` | Blur trigger | `trigger('focusout')` |
### Complexity Assessment
| Pattern | Complexity | Notes |
|---------|------------|-------|
| Single element update | Simple | Direct conversion |
| Dependent dropdowns | Simple | Use `select()` + `target()` |
| Multiple element updates | Medium | Use `swapOob()` |
| Custom commands | Medium | Use `triggerHeader()` + JS |
| Dialog integration | Complex | May keep AJAX |
| Heavy JS processing | Complex | Evaluate case-by-case |
```