A lightweight, zero-dependency JavaScript library for building dynamic UIs with REST APIs using declarative HTML attributes.
Like htmx, but instead of receiving HTML from the server, xhtmlx receives JSON and renders UI client-side using templates.
~17KB gzipped | 1024 tests | Zero dependencies | No build step | Up to 678x faster re-renders than React
Documentation & Demos | GitHub
<script src="xhtmlx.js"></script>
<div xh-get="/api/users" xh-trigger="load" xh-template="/templates/user-list.html">
<span class="xh-indicator">Loading...</span>
</div>
<div id="results"></div><!-- /templates/user-list.html -->
<div xh-each="users">
<div class="user">
<span xh-text="name"></span>
<span xh-text="email"></span>
</div>
</div>Server returns:
{ "users": [{ "name": "Alice", "email": "alice@example.com" }] }xhtmlx fetches the JSON, loads the template, renders it with the data, and swaps it into the DOM. No JavaScript needed.
Drop the script into your page:
<script src="xhtmlx.js"></script>No build step. No dependencies. TypeScript definitions are included (xhtmlx.d.ts).
| Attribute | Description |
|---|---|
xh-get |
Issue a GET request to the URL |
xh-post |
Issue a POST request |
xh-put |
Issue a PUT request |
xh-delete |
Issue a DELETE request |
xh-patch |
Issue a PATCH request |
<button xh-get="/api/users">Load Users</button>
<button xh-post="/api/users" xh-vals='{"name": "Bob"}'>Create User</button>
<button xh-delete="/api/users/{{id}}">Delete</button>External template file:
<div xh-get="/api/users" xh-template="/templates/user-list.html"></div>Inline template:
<div xh-get="/api/users">
<template>
<span xh-text="name"></span>
</template>
</div>Templates can contain xhtmlx attributes, enabling nested API calls and template composition:
<!-- /templates/user-card.html -->
<div class="card">
<h2 xh-text="name"></h2>
<div xh-get="/api/users/{{id}}/posts"
xh-trigger="load"
xh-template="/templates/post-list.html">
</div>
</div>| Attribute | Description |
|---|---|
xh-text |
Set element's textContent from data field |
xh-html |
Set element's innerHTML from data field (use with caution) |
xh-attr-* |
Set any attribute from data field |
<span xh-text="user.name"></span>
<div xh-html="user.bio"></div>
<img xh-attr-src="user.avatar" xh-attr-alt="user.name">
<a xh-attr-href="user.profile_url" xh-text="user.name"></a>Bind form inputs to data fields. Pre-fills inputs from data, auto-collects values on submit, and provides live reactivity when backed by a MutableDataContext.
<div xh-get="/api/user/1" xh-trigger="load">
<template>
<input type="text" xh-model="name">
<input type="email" xh-model="email">
<select xh-model="role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<input type="checkbox" xh-model="active">
<button xh-put="/api/user/1">Save</button>
</template>
</div>Supported elements: text inputs, textareas, selects, checkboxes, and radio buttons. Values from xh-model inputs are automatically included in request bodies alongside form fields and xh-vals.
Toggle CSS classes based on data fields:
<div xh-class-active="is_active" xh-class-highlight="is_featured">
This element gets "active" and "highlight" classes based on data.
</div>When backed by a MutableDataContext, class changes are live-reactive.
Toggle element visibility without removing from the DOM (unlike xh-if/xh-unless which remove elements):
<div xh-show="has_details">Visible when has_details is truthy</div>
<div xh-hide="is_loading">Hidden when is_loading is truthy</div>Reactivity-aware: when the data context is mutable, visibility updates automatically on data changes.
xh-each repeats the element for each item in an array:
<ul>
<li xh-each="items">
<span xh-text="name"></span> - <span xh-text="price"></span>
</li>
</ul>For data { "items": [{ "name": "A", "price": 10 }, { "name": "B", "price": 20 }] }, this renders two <li> elements.
Access the iteration index with $index:
<li xh-each="items">
<span xh-text="$index"></span>. <span xh-text="name"></span>
</li>| Attribute | Description |
|---|---|
xh-if |
Render element only if field is truthy |
xh-unless |
Render element only if field is falsy |
<span xh-if="is_admin" class="badge">Admin</span>
<span xh-unless="verified" class="warning">Unverified</span>xh-trigger specifies what event fires the request:
<div xh-get="/api/data" xh-trigger="load">Auto-load on page load</div>
<input xh-get="/api/search" xh-trigger="keyup changed delay:300ms">
<div xh-get="/api/feed" xh-trigger="every 5s">Polling</div>
<div xh-get="/api/more" xh-trigger="revealed">Load when scrolled into view</div>
<button xh-get="/api/data" xh-trigger="click once">Load once</button>Default triggers:
click— buttons, links, and general elementssubmit— formschange— inputs, selects, textareas
Modifiers:
once— fire only oncechanged— only fire if value changeddelay:Nms— debounce before firingthrottle:Nms— throttle firing ratefrom:selector— listen on a different element
| Attribute | Description |
|---|---|
xh-target |
CSS selector for where to place the rendered result |
xh-swap |
How to insert the content |
<button xh-get="/api/users" xh-target="#user-list" xh-swap="innerHTML">
Load Users
</button>
<div id="user-list"></div>Swap modes:
| Mode | Behavior |
|---|---|
innerHTML |
Replace target's children (default) |
outerHTML |
Replace target itself |
beforeend |
Append inside target |
afterbegin |
Prepend inside target |
beforebegin |
Insert before target |
afterend |
Insert after target |
delete |
Remove target |
none |
Don't swap (fire-and-forget) |
Use {{field}} in URLs to insert values from the current data context:
<div xh-each="users">
<button xh-get="/api/users/{{id}}/profile"
xh-template="/templates/profile.html">
View Profile
</button>
</div>Supports dot notation: {{user.address.city}}
Show a loading element while a request is in-flight:
<button xh-get="/api/data" xh-indicator="#spinner">Load</button>
<span id="spinner" class="xh-indicator">Loading...</span>The library adds the xh-request class to the indicator during requests. Default CSS is injected automatically to show/hide .xh-indicator elements.
| Attribute | Description |
|---|---|
xh-vals |
JSON string of values to send with the request |
xh-headers |
JSON string of custom headers |
<button xh-post="/api/users"
xh-vals='{"name": "Alice", "role": "admin"}'
xh-headers='{"X-Custom": "value"}'>
Create User
</button>For forms, form fields are automatically serialized:
<form xh-post="/api/users" xh-template="/templates/success.html">
<input name="name" type="text">
<input name="email" type="email">
<button type="submit">Create</button>
</form>Attach client-side event handlers without writing JavaScript:
<button xh-on-click="toggleClass:active">Toggle Active</button>
<button xh-on-click="addClass:highlight">Add Highlight</button>
<button xh-on-click="removeClass:highlight">Remove Highlight</button>
<button xh-on-click="remove">Remove Me</button>
<button xh-on-click="toggle:#details">Toggle Details</button>
<button xh-on-click="dispatch:myCustomEvent">Fire Event</button>Available actions:
| Action | Description |
|---|---|
toggleClass:name |
Toggle a CSS class on the element |
addClass:name |
Add a CSS class |
removeClass:name |
Remove a CSS class |
remove |
Remove the element from the DOM |
toggle:selector |
Toggle visibility of another element |
dispatch:eventName |
Dispatch a custom DOM event |
Update the browser URL after a successful request:
<!-- Push a new history entry -->
<button xh-get="/api/users/{{id}}"
xh-push-url="/users/{{id}}"
xh-target="#content">
View User
</button>
<!-- Replace the current history entry -->
<button xh-get="/api/search?q=test"
xh-replace-url="/search?q=test"
xh-target="#results">
Search
</button>Set xh-push-url="true" to use the request URL as the history URL. Back/forward navigation re-fetches data and re-renders the template.
Stream real-time data via WebSocket connections:
<!-- Connect to a WebSocket and render each incoming message -->
<div xh-ws="wss://example.com/feed"
xh-swap="beforeend"
xh-target="#messages">
<template>
<div class="message">
<strong xh-text="user"></strong>: <span xh-text="text"></span>
</div>
</template>
</div>
<div id="messages"></div>
<!-- Send data over an existing WebSocket connection -->
<form xh-ws-send="#chat-ws">
<input name="text" type="text">
<button type="submit">Send</button>
</form>WebSocket events: xh:wsOpen, xh:wsClose, xh:wsError. Auto-reconnects on unexpected disconnection after 3 seconds.
Prevent duplicate requests while one is already in-flight. Optionally apply a CSS class to indicate the disabled state:
<button xh-post="/api/submit"
xh-disabled-class="btn-loading">
Submit
</button>While a request is in-flight, the btn-loading class is added and aria-disabled="true" is set. Subsequent triggers are ignored until the request completes.
When new content is swapped into the DOM, xhtmlx applies transition classes for CSS animations:
xh-addedis applied immediately after insertion- After two animation frames,
xh-addedis removed andxh-settledis added
.xh-added { opacity: 0; transform: translateY(-10px); }
.xh-settled { opacity: 1; transform: translateY(0); transition: all 300ms ease; }Enhance regular <a> links and <form> elements to use AJAX instead of full page navigation:
<nav xh-boost xh-boost-target="#main-content" xh-boost-template="/templates/page.html">
<a href="/about">About</a>
<a href="/contact">Contact</a>
<form action="/api/search" method="POST">
<input name="q" type="text">
</form>
</nav>
<div id="main-content"></div>Boosted links automatically push browser history. Links with target="_blank", mailto:, or hash-only href values are not boosted.
Cache GET responses to avoid redundant network requests:
<!-- Cache for 60 seconds -->
<div xh-get="/api/config" xh-trigger="load" xh-cache="60">
<template><span xh-text="version"></span></template>
</div>
<!-- Cache forever (until page reload or manual cache clear) -->
<div xh-get="/api/static-data" xh-trigger="load" xh-cache="forever">
<template><span xh-text="label"></span></template>
</div>Clear programmatically with xhtmlx.clearResponseCache().
Automatically retry failed requests (5xx and network errors) with exponential backoff:
<div xh-get="/api/flaky-service"
xh-trigger="load"
xh-retry="3"
xh-retry-delay="1000">
<template><span xh-text="data"></span></template>
</div>xh-retry="3" retries up to 3 times. xh-retry-delay="1000" sets the base delay to 1000ms (doubled each attempt: 1s, 2s, 4s). Emits xh:retry events on each attempt.
Specify templates for error responses:
<div xh-get="/api/users"
xh-template="/templates/user-list.html"
xh-error-template="/templates/error.html"
xh-error-template-404="/templates/not-found.html"
xh-error-template-4xx="/templates/client-error.html"
xh-error-target="#error-area">
</div>Resolution order:
xh-error-template-{exact code}on the element (e.g.xh-error-template-404)xh-error-template-{class}on the element (e.g.xh-error-template-4xx)xh-error-templateon the element (generic fallback)- Nearest ancestor
xh-error-boundary(see below) xhtmlx.config.defaultErrorTemplate(global fallback)- No template: adds
xh-errorCSS class and emitsxh:responseErrorevent
Wrap a section of your page with xh-error-boundary to catch errors from any child widget that doesn't have its own error template:
<div xh-error-boundary
xh-error-template="/templates/error.html"
xh-error-target="#section-errors">
<div id="section-errors"></div>
<!-- If this fails and has no error template, the boundary catches it -->
<div xh-get="/api/widget-a" xh-trigger="load">
<template><span xh-text="data"></span></template>
</div>
<!-- This has its own error template, so the boundary is skipped -->
<div xh-get="/api/widget-b" xh-trigger="load"
xh-error-template="/templates/widget-error.html">
<template><span xh-text="data"></span></template>
</div>
</div>Boundaries support the same status-specific attributes: xh-error-template-404, xh-error-template-4xx, etc.
Boundaries nest — the nearest ancestor boundary catches the error:
<div xh-error-boundary xh-error-template="/templates/page-error.html">
<div xh-error-boundary xh-error-template="/templates/section-error.html">
<!-- Errors here go to section-error, not page-error -->
<div xh-get="/api/data" xh-trigger="load">...</div>
</div>
</div>Error containers are automatically marked with role="alert" for screen readers.
Set a page-wide default for widgets without any error handling:
<script>
xhtmlx.config.defaultErrorTemplate = "/templates/error.html";
xhtmlx.config.defaultErrorTarget = "#global-error";
</script>
<div id="global-error"></div>Any widget that errors without an element-level template or boundary will use this global fallback.
Error data context:
{
"status": 422,
"statusText": "Unprocessable Entity",
"body": { "error": "validation_failed", "fields": [...] }
}Use it in templates like any other data:
<!-- /templates/validation-error.html -->
<div class="error">
<h3>Error <span xh-text="status"></span></h3>
<ul xh-each="body.fields">
<li><strong xh-text="name"></strong>: <span xh-text="message"></span></li>
</ul>
</div>Validate inputs before sending requests. Validation runs automatically; if it fails, the request is blocked and xh:validationError is emitted.
<form xh-post="/api/register">
<input name="username" xh-validate="required"
xh-validate-minlength="3"
xh-validate-maxlength="20"
xh-validate-message="Username must be 3-20 characters"
xh-validate-target="#username-error">
<span id="username-error"></span>
<input name="email" xh-validate="required"
xh-validate-pattern="^[^@]+@[^@]+$">
<input name="age" type="number"
xh-validate="required"
xh-validate-min="18"
xh-validate-max="120">
<button type="submit">Register</button>
</form>Validation attributes:
| Attribute | Description |
|---|---|
xh-validate="required" |
Field must not be empty |
xh-validate-pattern |
Regex the value must match |
xh-validate-min / xh-validate-max |
Numeric range |
xh-validate-minlength / xh-validate-maxlength |
String length range |
xh-validate-message |
Custom error message |
xh-validate-class |
CSS class for invalid fields (default: xh-invalid) |
xh-validate-target |
CSS selector where error message is displayed |
Extend xhtmlx with custom directives, hooks, and transforms.
Custom directives:
xhtmlx.directive("xh-tooltip", function(el, value, ctx) {
el.title = ctx.resolve(value);
});<span xh-tooltip="help_text">Hover me</span>Global hooks:
xhtmlx.hook("beforeRequest", function(detail) {
detail.headers["Authorization"] = "Bearer " + getToken();
// Return false to cancel the request
});Transforms (pipe syntax):
xhtmlx.transform("currency", function(value) {
return "$" + Number(value).toFixed(2);
});
xhtmlx.transform("uppercase", function(value) {
return String(value).toUpperCase();
});<span xh-text="price | currency"></span>
<span xh-text="name | uppercase"></span>Pipes can be chained: "value | trim | uppercase".
Hot-swap UI templates and API endpoints without a full page reload:
// Switch all templates to load from /ui/v2/...
xhtmlx.switchVersion("v2");
// Custom prefixes
xhtmlx.switchVersion("abc123", {
templatePrefix: "/static/abc123",
apiPrefix: "/api/v2"
});
// Reload specific widgets
xhtmlx.reload("/templates/user-list.html");
// Reload all active widgets
xhtmlx.reload();switchVersion() clears template and response caches, then re-renders all active widgets. Emits xh:versionChanged.
Translate text content and attributes with locale dictionaries:
xhtmlx.i18n.load("en", {
"greeting": "Hello, {name}!",
"submit": "Submit",
"search_placeholder": "Search..."
});
xhtmlx.i18n.load("es", {
"greeting": "Hola, {name}!",
"submit": "Enviar",
"search_placeholder": "Buscar..."
});<h1 xh-i18n="greeting" xh-i18n-vars='{"name": "Alice"}'></h1>
<button xh-i18n="submit"></button>
<input xh-i18n-placeholder="search_placeholder">Switch locale at runtime:
xhtmlx.i18n.locale = "es"; // Re-renders all xh-i18n elements, emits xh:localeChangedProgrammatic translation: xhtmlx.i18n.t("greeting", { name: "Bob" }).
Client-side routing with path parameters:
<nav xh-router xh-router-outlet="#view" xh-router-404="/templates/404.html">
<a xh-route="/" xh-template="/templates/home.html">Home</a>
<a xh-route="/users" xh-get="/api/users" xh-template="/templates/users.html">Users</a>
<a xh-route="/users/:id" xh-get="/api/users/{{id}}" xh-template="/templates/user.html">User</a>
</nav>
<div id="view"></div>The active route link receives the xh-route-active CSS class. Path parameters (:id) are extracted and available in the data context. Handles browser back/forward via popstate. Navigate programmatically with xhtmlx.router.navigate("/users/42"). Emits xh:routeChanged and xh:routeNotFound.
xhtmlx includes built-in accessibility features:
aria-busy="true"is set on swap targets during in-flight requests and removed on completionaria-liveis auto-applied toxh-targetelements (defaults to"polite", override withxh-aria-live="assertive")role="alert"is set on error containers after error template renderingaria-disabled="true"is set during request deduplication (withxh-disabled-class)xh-focusmanages focus after content swaps:
<!-- Focus a specific element after swap -->
<button xh-get="/api/form" xh-target="#panel" xh-focus="#panel input:first-child">Open Form</button>
<!-- Auto-focus the first focusable element in the swapped content -->
<button xh-get="/api/data" xh-target="#content" xh-focus="auto">Load</button>xhtmlx emits custom DOM events for programmatic control:
| Event | When | Cancelable |
|---|---|---|
xh:beforeRequest |
Before fetch fires | Yes |
xh:afterRequest |
After fetch completes | No |
xh:beforeSwap |
Before DOM swap | Yes |
xh:afterSwap |
After DOM swap | No |
xh:responseError |
On HTTP error response | No |
xh:retry |
Before each retry attempt | No |
xh:validationError |
When validation fails | No |
xh:wsOpen |
WebSocket connected | No |
xh:wsClose |
WebSocket disconnected | No |
xh:wsError |
WebSocket error | No |
xh:versionChanged |
After switchVersion() |
No |
xh:localeChanged |
After locale switch | No |
xh:routeChanged |
After route navigation | No |
xh:routeNotFound |
No matching route | No |
document.body.addEventListener("xh:responseError", function(e) {
console.log(e.detail.status);
console.log(e.detail.body);
});Data flows through nested templates via a context chain. Child templates can access parent data:
<!-- Parent: fetches user -->
<div xh-get="/api/users/1" xh-trigger="load" xh-template="/templates/user.html"></div>
<!-- /templates/user.html: can access user fields, fetches posts -->
<h1 xh-text="name"></h1>
<div xh-get="/api/users/{{id}}/posts" xh-trigger="load">
<template>
<!-- Each post can access its own fields AND parent user fields via $parent -->
<div xh-each="posts">
<p><span xh-text="title"></span> by <span xh-text="$parent.name"></span></p>
</div>
</template>
</div>Special variables:
$index— current iteration index (insidexh-each)$parent— parent data context$root— topmost data context
xhtmlx.config.debug = true; // Enable debug logging
xhtmlx.config.defaultSwapMode = "innerHTML"; // Default swap mode
xhtmlx.config.batchThreshold = 100; // xh-each batch threshold
xhtmlx.config.templatePrefix = ""; // Prefix for template URLs
xhtmlx.config.apiPrefix = ""; // Prefix for API URLs
xhtmlx.config.defaultErrorTemplate = null; // Global error template
xhtmlx.config.defaultErrorTarget = null; // Global error targetxhtmlx is benchmarked head-to-head against React 19 on every release. The numbers below are averages of 3 runs in JSDOM with Jest (flushSync for React to keep the comparison synchronous).
| Scenario | xhtmlx | React 19 | Winner |
|---|---|---|---|
| 1 text — changing data | 13.48M ops/s | 49.7K ops/s | xhtmlx 271x |
| 1 text — same data (noop) | 12.81M ops/s | 32.0K ops/s | xhtmlx 400x |
| 5 text — same data (noop) | 32.48M ops/s | 128.1K ops/s | xhtmlx 253x |
| 10 text — changing data | 1.45M ops/s | 8.1K ops/s | xhtmlx 178x |
| Card — changing data | 5.98M ops/s | 25.2K ops/s | xhtmlx 237x |
| Card — same data (noop) | 20.67M ops/s | 30.5K ops/s | xhtmlx 678x |
| Conditional — same (noop) | 16.11M ops/s | 44.9K ops/s | xhtmlx 359x |
| Profile — same (noop) | 14.82M ops/s | 22.9K ops/s | xhtmlx 647x |
xhtmlx's render() API patches only changed DOM bindings in place — no virtual DOM diff, no reconciliation, no tree walk. This is where the library shines for polling, WebSocket streams, and reactive state updates: 178–678x faster than React.
| Scenario | xhtmlx | React 19 | Winner |
|---|---|---|---|
| Single text binding | 1.41M ops/s | 25.4K ops/s | xhtmlx 56x |
| 5 text bindings | 1.47M ops/s | 55.7K ops/s | xhtmlx 26x |
| Conditional render | 1.52M ops/s | 17.0K ops/s | xhtmlx 89x |
| User profile card | 1.37M ops/s | 8.0K ops/s | xhtmlx 171x |
| List — 100 items | 1.50M ops/s | 815 ops/s | xhtmlx 1,836x |
| List — 500 items | 1.76M ops/s | 209 ops/s | xhtmlx 8,405x |
| List — 1,000 items | 1.94M ops/s | 95 ops/s | xhtmlx 20,493x |
performSwap now auto-patches when the same template is re-rendered into the same target — the DOM is never rebuilt, only changed bindings are updated. Combined with lazy fragment construction, the full pipeline matches render() speed: 26–20,493x faster than React.
| Category | Winner | Magnitude |
|---|---|---|
Re-render / patching (render()) |
xhtmlx | 178–678x faster |
| Render + swap pipeline | xhtmlx | 26–20,493x faster |
| Bundle size | xhtmlx | 2.7x smaller (~17KB vs ~46KB+) |
Benchmarks:
tests/benchmark/— React 19.2, xhtmlx 0.4.1, JSDOM, 3 runs averaged.
Use the built-in CLI tool to migrate HTML files between xhtmlx versions:
# Migrate v1 → v2
npx xhtmlx-migrate --from=1 --to=2 src/
# Preview changes without modifying files
npx xhtmlx-migrate --dry-run --from=1 --to=2 src/
# Rollback v2 → v1
npx xhtmlx-migrate --from=1 --to=2 --reverse src/
# Show available migration rules
npx xhtmlx-migrate --list-rules --from=1 --to=2The tool scans HTML files for deprecated xh-* attributes and updates them automatically. Supports recursive directory scanning, dry-run preview, and reverse migrations for rollback.
See the Migration Guide for detailed version-by-version changes.
xhtmlx uses fetch(), Promise, WeakMap, and IntersectionObserver. Works in all modern browsers (Chrome, Firefox, Safari, Edge). No IE support.
MIT