Plugin architecture for extending htmx functionality with custom behaviors, integrations, and advanced features through a comprehensive extension API.
Register and manage htmx extensions to extend core functionality.
/**
* Initializes and registers an extension
* @param name - Extension name for reference and activation
* @param extension - Extension definition object implementing HtmxExtension interface
*/
function defineExtension(name: string, extension: Partial<HtmxExtension>): void;
/**
* Removes an extension from the registry
* @param name - Extension name to remove
*/
function removeExtension(name: string): void;Basic Extension Example:
// Simple logging extension
htmx.defineExtension('logger', {
onEvent: function(name, evt) {
console.log('HTMX Event:', name, evt.detail);
return true; // Continue processing
}
});
// Activate extension in HTML
// <div hx-ext="logger">Content with logging enabled</div>
// Remove extension
htmx.removeExtension('logger');Complete extension interface for implementing sophisticated extensions.
interface HtmxExtension {
/**
* Initialize extension with htmx internal API
* @param api - htmx internal API object
*/
init: (api: any) => void;
/**
* Handle htmx events before core processing
* @param name - Event name
* @param event - Custom event object
* @returns boolean - true to continue processing, false to stop
*/
onEvent: (name: string, event: CustomEvent) => boolean;
/**
* Transform response text before processing
* @param text - Original response text
* @param xhr - XMLHttpRequest object
* @param elt - Triggering element
* @returns Modified response text
*/
transformResponse: (text: string, xhr: XMLHttpRequest, elt: Element) => string;
/**
* Determine if swap style should be handled inline
* @param swapStyle - Swap style string
* @returns true if swap should be handled by extension
*/
isInlineSwap: (swapStyle: HtmxSwapStyle) => boolean;
/**
* Handle custom swap operations
* @param swapStyle - Swap style string
* @param target - Target element for swap
* @param fragment - Content fragment to swap
* @param settleInfo - Settlement information
* @returns boolean or Node array - true if handled, false to continue, or nodes to settle
*/
handleSwap: (swapStyle: HtmxSwapStyle, target: Node, fragment: Node, settleInfo: HtmxSettleInfo) => boolean | Node[];
/**
* Encode request parameters
* @param xhr - XMLHttpRequest object
* @param parameters - FormData parameters
* @param elt - Triggering element
* @returns Encoded parameters as string, object, or null
*/
encodeParameters: (xhr: XMLHttpRequest, parameters: FormData, elt: Node) => any | string | null;
/**
* Return CSS selectors for elements this extension should process
* @returns Array of CSS selectors or null
*/
getSelectors: () => string[] | null;
}
type HtmxSwapStyle = "innerHTML" | "outerHTML" | "beforebegin" | "afterbegin" | "beforeend" | "afterend" | "delete" | "none" | string;
interface HtmxSettleInfo {
tasks: HtmxSettleTask[];
elts: Element[];
title?: string;
}
type HtmxSettleTask = () => void;htmx.defineExtension('auth', {
onEvent: function(name, evt) {
if (name === 'htmx:configRequest') {
const xhr = evt.detail.xhr;
const token = localStorage.getItem('authToken');
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
}
if (name === 'htmx:responseError') {
const xhr = evt.detail.xhr;
if (xhr.status === 401) {
// Redirect to login on authentication failure
window.location.href = '/login';
return false; // Stop further processing
}
}
return true;
}
});htmx.defineExtension('json-enc', {
onEvent: function(name, evt) {
if (name === 'htmx:configRequest') {
const xhr = evt.detail.xhr;
const elt = evt.detail.elt;
// Check if element wants JSON encoding
if (elt.getAttribute('hx-ext')?.includes('json-enc')) {
xhr.setRequestHeader('Content-Type', 'application/json');
}
}
return true;
},
encodeParameters: function(xhr, parameters, elt) {
// Only encode as JSON if this extension is active
if (elt.getAttribute('hx-ext')?.includes('json-enc')) {
const obj = {};
parameters.forEach((value, key) => {
obj[key] = value;
});
return JSON.stringify(obj);
}
return null; // Let htmx handle normally
}
});
// Usage in HTML:
// <form hx-post="/api/users" hx-ext="json-enc">
// <input name="name" value="John">
// <input name="email" value="john@example.com">
// <button type="submit">Create User</button>
// </form>htmx.defineExtension('loading-states', {
onEvent: function(name, evt) {
const elt = evt.detail.elt;
if (name === 'htmx:beforeRequest') {
// Add loading class to triggering element
elt.classList.add('htmx-loading');
elt.disabled = true;
// Store original text if it's a button
if (elt.tagName === 'BUTTON') {
elt.dataset.originalText = elt.textContent;
elt.textContent = 'Loading...';
}
// Show loading indicator if specified
const indicator = document.querySelector(elt.getAttribute('loading-target'));
if (indicator) {
indicator.style.display = 'block';
}
}
if (name === 'htmx:afterRequest') {
// Remove loading states
elt.classList.remove('htmx-loading');
elt.disabled = false;
// Restore original button text
if (elt.tagName === 'BUTTON' && elt.dataset.originalText) {
elt.textContent = elt.dataset.originalText;
delete elt.dataset.originalText;
}
// Hide loading indicator
const indicator = document.querySelector(elt.getAttribute('loading-target'));
if (indicator) {
indicator.style.display = 'none';
}
}
return true;
}
});htmx.defineExtension('markdown', {
transformResponse: function(text, xhr, elt) {
// Check if response should be processed as markdown
const contentType = xhr.getResponseHeader('Content-Type');
if (contentType?.includes('text/markdown') ||
elt.getAttribute('hx-markdown') !== null) {
// Convert markdown to HTML (assuming marked.js is loaded)
if (typeof marked !== 'undefined') {
return marked(text);
}
}
return text; // Return unchanged if not markdown
}
});
// Usage:
// <div hx-get="/api/readme.md" hx-ext="markdown" hx-markdown></div>htmx.defineExtension('fade-swap', {
isInlineSwap: function(swapStyle) {
return swapStyle === 'fade';
},
handleSwap: function(swapStyle, target, fragment, settleInfo) {
if (swapStyle === 'fade') {
// Fade out current content
target.style.transition = 'opacity 0.3s';
target.style.opacity = '0';
setTimeout(() => {
// Replace content
target.innerHTML = fragment.innerHTML;
// Fade in new content
target.style.opacity = '1';
// Add settle tasks for new elements
const newElements = target.querySelectorAll('*');
newElements.forEach(el => {
settleInfo.elts.push(el);
});
// Run settle tasks
settleInfo.tasks.forEach(task => task());
}, 300);
return true; // Handled by extension
}
return false; // Not handled
}
});
// Usage:
// <button hx-get="/content" hx-swap="fade" hx-target="#main">Load Content</button>htmx.defineExtension('debug', {
init: function(api) {
console.log('Debug extension initialized with API:', api);
this.api = api;
},
onEvent: function(name, evt) {
console.group(`π§ HTMX Debug: ${name}`);
console.log('Event:', evt);
console.log('Detail:', evt.detail);
console.log('Target:', evt.target);
console.groupEnd();
// Log specific event details
if (name === 'htmx:configRequest') {
console.table({
Method: evt.detail.verb,
URL: evt.detail.path,
Headers: evt.detail.headers,
Parameters: evt.detail.parameters
});
}
if (name === 'htmx:afterRequest') {
console.table({
Status: evt.detail.xhr.status,
'Status Text': evt.detail.xhr.statusText,
'Response Length': evt.detail.xhr.responseText.length,
Success: evt.detail.successful
});
}
return true;
},
transformResponse: function(text, xhr, elt) {
console.log('π Response Transform:', {
length: text.length,
contentType: xhr.getResponseHeader('Content-Type'),
element: elt
});
return text;
}
});htmx.defineExtension('validate', {
onEvent: function(name, evt) {
if (name === 'htmx:beforeRequest') {
const form = evt.detail.elt.closest('form');
if (form && form.hasAttribute('hx-validate')) {
// Clear previous errors
form.querySelectorAll('.error').forEach(error => {
error.remove();
});
// Validate required fields
const requiredFields = form.querySelectorAll('[required]');
let hasErrors = false;
requiredFields.forEach(field => {
if (!field.value.trim()) {
hasErrors = true;
const error = document.createElement('div');
error.className = 'error';
error.textContent = `${field.name || 'This field'} is required`;
field.parentNode.insertBefore(error, field.nextSibling);
}
});
// Validate email fields
const emailFields = form.querySelectorAll('input[type="email"]');
emailFields.forEach(field => {
if (field.value && !isValidEmail(field.value)) {
hasErrors = true;
const error = document.createElement('div');
error.className = 'error';
error.textContent = 'Please enter a valid email address';
field.parentNode.insertBefore(error, field.nextSibling);
}
});
if (hasErrors) {
evt.preventDefault(); // Stop the request
return false;
}
}
}
return true;
}
});
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}// Extension that coordinates with other extensions
htmx.defineExtension('coordinator', {
init: function(api) {
this.extensions = new Map();
this.api = api;
},
registerExtension: function(name, callbacks) {
this.extensions.set(name, callbacks);
},
onEvent: function(name, evt) {
// Notify all registered extensions
this.extensions.forEach((callbacks, extName) => {
if (callbacks[name]) {
callbacks[name](evt);
}
});
return true;
}
});
// Usage by other extensions
const coordinator = htmx.extensions['coordinator'];
if (coordinator) {
coordinator.registerExtension('myExtension', {
'htmx:beforeRequest': function(evt) {
console.log('My extension handling beforeRequest');
}
});
}htmx.defineExtension('configurable', {
init: function(api) {
// Read configuration from meta tags or global config
this.config = {
timeout: parseInt(document.querySelector('meta[name="htmx-timeout"]')?.content) || 5000,
retries: parseInt(document.querySelector('meta[name="htmx-retries"]')?.content) || 3,
baseURL: document.querySelector('meta[name="htmx-base-url"]')?.content || ''
};
},
onEvent: function(name, evt) {
if (name === 'htmx:configRequest') {
// Apply configuration
const xhr = evt.detail.xhr;
xhr.timeout = this.config.timeout;
// Modify URL if base URL is configured
if (this.config.baseURL && !evt.detail.path.startsWith('http')) {
evt.detail.path = this.config.baseURL + evt.detail.path;
}
}
return true;
}
});// Extension that only activates under certain conditions
htmx.defineExtension('conditional', {
init: function(api) {
this.active = this.shouldActivate();
},
shouldActivate: function() {
// Check various conditions
return window.innerWidth < 768 || // Mobile only
localStorage.getItem('debug') === 'true' || // Debug mode
document.body.classList.contains('dev-mode'); // Development environment
},
onEvent: function(name, evt) {
if (!this.active) return true;
// Extension logic only runs when active
console.log('Conditional extension active for:', name);
return true;
}
});// Load extensions dynamically
async function loadExtension(name, url) {
try {
const response = await fetch(url);
const extensionCode = await response.text();
// Create a safe execution context
const extensionFunction = new Function('htmx', extensionCode);
extensionFunction(htmx);
console.log(`Extension ${name} loaded successfully`);
} catch (error) {
console.error(`Failed to load extension ${name}:`, error);
}
}
// Usage
loadExtension('custom-auth', '/js/extensions/auth.js');// Global extension registry
window.htmxExtensions = {
available: new Map(),
loaded: new Set(),
register: function(name, factory) {
this.available.set(name, factory);
},
load: function(name) {
if (this.loaded.has(name)) return;
const factory = this.available.get(name);
if (factory) {
htmx.defineExtension(name, factory());
this.loaded.add(name);
}
},
autoLoad: function() {
// Auto-load extensions based on hx-ext attributes
document.querySelectorAll('[hx-ext]').forEach(el => {
const extensions = el.getAttribute('hx-ext').split(',');
extensions.forEach(ext => {
this.load(ext.trim());
});
});
}
};
// Register extensions
htmxExtensions.register('my-extension', () => ({
onEvent: function(name, evt) {
console.log('My extension:', name);
return true;
}
}));
// Auto-load on page load
document.addEventListener('DOMContentLoaded', () => {
htmxExtensions.autoLoad();
});