htmx 4.0 is under construction — migration guide

Expressions live in HTML attributes. They read from the page, write to it, and re-run as it changes.

<input type="text"> <p :text="'Hello, ' + q('previous input').value"></p>

The paragraph updates as you type.

Installing

<script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/ext/hx-live.min.js"></script>

Attributes

:<attr>

Prefix any HTML attribute with : and put an expression in it. The result is written to the attribute.

<input id="name"> <button :disabled="!q('#name').value">Submit</button>

The same shape works for any attribute:

<a :href="'/users/' + q('#user-id').value">profile</a> <button :hidden="q('.row').count === 0">Clear all</button> <input :required="q('#mode').value === 'final'">

⚠️ Alpine.js conflict: The : short form uses the same syntax as Alpine.js (x-bind:). If Alpine is detected on the page at initialization time, hx-live automatically disables the : short form and logs a console warning. You can override this behavior by explicitly setting config.live.bindPrefix.

How each attribute is written (booleans, ARIA, property-backed, generic) is described in Attribute writing rules.

hx-live:<attr>

The full form. Behaves identically to :<attr>.

<button hx-live:disabled="!q('#name').value">Submit</button>

Use it if your build pipeline strips :-prefixed attributes.

:.<class>

Bind a single class to an expression. Truthy adds it, falsy removes it.

<input type="number" value="0"> <p :.warn="q('previous input').valueAsNumber < 0">Negative balance</p>

:class

String form: set the listed classes.

<input type="number" value="0"> <div :class="q('previous input').valueAsNumber < 18 ? 'warn big' : 'ok'"></div>

Object form: each key is added or removed by the truthiness of its value.

<input type="number" value="0"> <div :class="{ warn: q('previous input').valueAsNumber < 18, ok: q('previous input').valueAsNumber >= 18 }"></div>

A key may list several classes that share one condition. Quote the key when it contains spaces.

<input id="strict" type="checkbox"> <div :class="{ 'warn big': q('#strict').checked }">Notice</div>

:class only manages classes it writes. Other classes set in HTML are untouched. If a class appears both statically and in the binding, the binding wins.

:text

Bind the element’s textContent to an expression.

<input type="number" value="2"> <input type="number" value="3"> <p :text="q('first input').valueAsNumber * q('last input').valueAsNumber"></p>

Numbers and other non-strings are stringified.

:html

Bind the element’s innerHTML to an expression.

<input value="world"> <div :html="`<b>${q('previous input').value}</b>`"></div>

Make sure to sanitize anything untrusted.

:style

String form: a CSS declaration string.

<input id="pct" type="range" value="50"> <div :style="`width: ${q('#pct').value}%; height: 8px; background: tomato`"></div>

Object form: each key sets a CSS property. Camel-case keys convert to kebab-case.

<input id="pct" type="range" value="50"> <input id="color" type="color" value="#ff0000"> <div :style="{ width: q('#pct').value + '%', backgroundColor: q('#color').value, height: '8px' }"></div>

:style only manages properties it writes. Other inline style properties are untouched. If a property appears both statically and in the binding, the binding wins.

hx-live

An escape hatch. Use it when no single :<attr> fits, or for multi-step logic and side effects.

<input placeholder="search"> <div hx-live=" let term = q('previous input').value; if (!term) { this.textContent = ''; return; } await debounce(250); this.textContent = await fetch('/search?q=' + encodeURIComponent(term)) .then(r => r.text()); "></div>

Helpers

The helpers work inside hx-live expressions, inside hx-on event handlers, and from regular JavaScript via htmx.live.*.

htmx.live.q('.row').attr('hidden', true);

Inside expressions, this is the element, the full htmx API is available unprefixed, and await works at the top level (expressions are async functions).

<button hx-on:click=" attr('disabled', true); await ajax('POST', '/save'); attr('disabled', false); ">Save</button>

q()

q() returns a proxy over a set of elements. Read from the first match, write to all.

q('.row') // every .row in the document q('#bar') // single element by id q(element) // wrap an existing element q(nodeList) // wrap a collection q('.row').count // number of matches q('.row').arr() // Array<Element> for (let e of q('.row')) {...} // iterate q('input').value // value of the first match q('input').value = '' // assign to every match q('.row').classList.add('done') // method calls chain through q('.row').dataset.state = 'on' q('button').click()

Selector grammar

q('first .foo') // first match in document order q('last .foo') // last match q('next .foo') // first match after this element q('previous .foo') // closest match before this element q('closest .foo') // nearest ancestor matching .foo q('.foo in #scope') // restrict to a specific root q('.foo in this') // restrict to the current element

next, previous, and closest resolve against this (the element that owns the expression). They only work inside hx-live / hx-on scopes.

Chaining

.q(...) on a proxy re-runs the grammar with each element as the anchor:

q('.error').q('closest .field') // surrounding .field of each .error q('section').q('first .item') // first .item per section q('.row').q('next .row') // each row's successor

For plain descendant queries, CSS is shorter: q('.card .title') and q('.card').q('.title') are equivalent. Use chaining when you need a directional per matched element.

Built-in methods

The helpers below also work as methods on the proxy, applying across all matched elements:

q('input').attr('disabled', true) // set attribute on all q('.row').toggle('.selected') // toggle class on each q('.tab.active').take('.active', '.tab') // move a class from peers to self q('.tab').trigger('select', { id: 1 }) // CustomEvent on each q('.list').insert('end', '<li>new</li>') // before / after / start / end

attr(name, value?)

Get or set an attribute, class, or property on this element. Pass one argument to read, two to write.

attr('hidden') // is hidden present? attr('hidden', true) // add (false/null/undefined removes) attr('.active') // has class .active? attr('.active', q('#src').checked) // add/remove class attr('class', 'foo bar') // multi-class string attr('class', { active: matches('.tab') }) // multi-class object attr('aria-expanded', matches('.open')) // any aria-*: writes "true"/"false" attr('value', 'hello') // value/checked/selected: syncs property + attribute attr('data-x', null) // remove

toggle(name, values?)

Toggle (no values) or cycle (with values) a class or attribute on this element.

toggle('.active') // toggle class toggle('aria-expanded') // flip "true" ↔ "false" toggle('hidden') // toggle attribute presence toggle('data-view', 'grid|list|table') // cycle attribute through values toggle('.size', 'sm|md|lg') // cycle classes (only one at a time) toggle('data-open', 'on|') // cycle: 'on' ↔ absent

values accepts a |-separated string or an array.

take(name, scope?)

Move a class or attribute from siblings to this element. Pass a scope selector to widen or restrict the source set.

take('.selected', '.tab') // become the selected tab among .tab take('aria-current', 'nav a') // become the current nav item take('.active') // implicit scope: parent element's subtree

data

Read or write data-* attributes on the closest ancestor that has them. Lets components share state up the tree.

<div data-size="medium"> <button hx-on:click="data.size = 'small'">S</button> <button hx-on:click="data.size = 'medium'">M</button> <button hx-on:click="data.size = 'large'">L</button> <p :text="`Size: ${data.size}`"></p> </div>

data.foo reads from the closest [data-foo] ancestor. Writing assigns to that ancestor too.

Values are automatically JSON-serialized on write and parsed on read. Booleans, numbers, arrays, and objects round-trip transparently:

<div data-count="1" data-active="false" data-cart="[]"> <input id="sku" placeholder="Product code"> <button hx-on:click="data.cart = [...data.cart, {sku: q('#sku').value, qty: data.count}]">Add to cart</button> <button hx-on:click="data.count++">+</button> <button hx-on:click="data.count--">−</button> <button hx-on:click="data.active = !data.active">Toggle details</button> <p :text="`Qty: ${data.count} | ${data.cart.length} items in cart`"></p> </div>

Plain strings that aren’t valid JSON are returned as-is.

data is also available on q() proxies via q(selector).data. It cascades from the first matched element:

q('#cart-panel').data.items // read: JSON-parsed value from closest [data-items] ancestor q('#cart-panel').data.items = [{id: 1}] // write: JSON-stringified to that ancestor

For direct, this-only access, use this.dataset instead (note: this.dataset is always strings). For per-element writes across a set, use q('.row').dataset.state = 'on'.

Because :<attr> works on data-*, you can also store derived values in the DOM:

<div data-first="Ada" data-last="Lovelace" :data-full="data.first + ' ' + data.last"> <span :text="data.full"></span> </div>

style

Shorthand for this.style.

<input type="color" value="#ff0000"> <button hx-on:click="style.setProperty('--accent', q('previous input').value)">Apply</button>

classList

Shorthand for this.classList.

<button hx-on:click="classList.add('shake')">Wiggle</button>

matches(selector)

Shorthand for this.matches(selector).

<button :aria-busy="matches('.htmx-request')" hx-post="/save">Save</button>

trigger(type, detail?, bubbles?)

Dispatch a CustomEvent from this element.

<li hx-on:click="trigger('select', { id: this.dataset.id })" data-id="42">Item</li>

insert(position, html)

Insert an HTML string. Wraps insertAdjacentHTML with friendlier position names: before and after for siblings, start and end for children.

insert('start', '<li>first</li>') // first child insert('end', '<li>last</li>') // last child insert('before', '<hr>') // sibling before insert('after', '<hr>') // sibling after
<ul hx-on:click="insert('end', '<li>+</li>')">Click to add a row</ul>

Sanitize anything untrusted.

debounce(ms)

Wait ms milliseconds. If called again on the same element before resolving, the previous call is cancelled.

<input placeholder="search"> <div hx-live=" await debounce(200); this.textContent = await fetch('/q?term=' + q('previous input').value).then(r => r.text()); "></div>

Each element has its own channel.

forEvent(...args)

Resolve on the next matching event. Mix event names, milliseconds, intervals, and target elements. First to fire wins.

await forEvent('click') // next click on this element await forEvent('click', 1000) // click OR 1s timeout await forEvent('a', 'b', '5s') // any number of events / intervals

Typical use: wait for a CSS transition to finish, with a safety timeout.

<button hx-on:click=" classList.add('fade-out'); await forEvent('transitionend', 500); this.remove(); ">Dismiss</button>

nextFrame()

Resolve on the next animation frame.

<button hx-on:click=" classList.remove('shake'); await nextFrame(); classList.add('shake'); ">Replay shake</button>

ARIA as state

ARIA attributes serve two purposes: they describe the component to assistive tech, and they hold UI state.

Bind them with :aria-* and drive CSS off the same attribute. You avoid .is-open, .active, and .loading classes.

AttributeMeaningTypical UI use
aria-expanded”is open”Disclosure, menu, accordion
aria-selected”is the active one”Tabs, listbox option
aria-pressed”toggle is on”Toggle button (bold, mute)
aria-checked”checkbox state”Custom checkboxes, radios
aria-busy”is loading”Form during submit, list during fetch
aria-disabled”can’t interact”Greyed-out non-button control
aria-current”the current one”Nav item, breadcrumb, step
aria-hidden”hidden from a11y”Decorative content

Disclosure.

For a single inline section, native <details> is the right tool. Use aria-expanded when the trigger and target are separated in the DOM.

<header> <button hx-on:click="toggle('aria-expanded')" aria-expanded="false">Menu</button> </header> <aside :hidden="!q('header button').attr('aria-expanded')">...</aside>

Toggle button.

<button hx-on:click="toggle('aria-pressed')" aria-pressed="false">Bold</button>
[aria-pressed="true"] { background: lightblue }

Tabs.

<div role="tablist"> <button role="tab" hx-on:click="take('aria-selected', '[role=tab]')" aria-selected="true">A</button> <button role="tab" hx-on:click="take('aria-selected', '[role=tab]')">B</button> <button role="tab" hx-on:click="take('aria-selected', '[role=tab]')">C</button> </div>

take('aria-selected', '[role=tab]') writes "false" on every [role=tab], then "true" on this one.

Loading state.

<form :aria-busy="matches('.htmx-request')" hx-post="/save"> <input name="email"> <button type="submit">Save</button> </form>
[aria-busy="true"] { opacity: 0.5; pointer-events: none }

Non-boolean ARIA. Strings pass through, so aria-current="page", aria-pressed="mixed", and numeric ARIA (aria-valuenow="50") work in the simple form:

<a :aria-current="location.pathname === '/home' ? 'page' : false" href="/home">Home</a> <button :aria-pressed="state.bold ? 'mixed' : !!state.bold">Bold</button> <div role="slider" :aria-valuenow="q('#slider').valueAsNumber"></div>

How it works

Re-run triggers

A single document-wide MutationObserver and input / change listeners trigger a recompute of every live expression. Any of these schedule one:

  • DOM additions, removals, attribute changes, text changes
  • input or change events from any control
  • completion of an htmx swap (recomputes pause mid-swap, run once at the end)

All expressions run in a single microtask, so multiple synchronous mutations coalesce into one recompute.

Self-mutation is safe

When an expression writes to the DOM, the observer drains its own pending records inside the same microtask. Writes made by hx-live cannot trigger a feedback loop.

Runaway cap

If recomputes exceed 50/sec, the extension logs a warning. Bindings continue running. Tune your expression or add debounce.

Coordinating with htmx swaps

Recomputes are deferred between htmx:before:swap and htmx:swap:finally. One consolidated recompute runs when the swap finishes, regardless of how much markup changed.

Cleanup

When an hx-live element is removed, its expression drops out on the next scheduled run. When all expressions are gone, the observer and listeners detach.

hx-ignore descendants are not registered.

Boolean, ARIA, and other attribute kinds

:<attr> writes the value differently depending on the attribute, following HTML conventions.

Boolean attributes (disabled, hidden, required, open, readonly, inert, …). Truthy adds the attribute; falsy removes it.

<button :disabled="truthyExpr"> <!-- <button disabled=""> --> <button :disabled="falsyExpr"> <!-- <button> --> <div :hidden="truthyExpr"> <!-- <div hidden=""> --> <div :hidden="falsyExpr"> <!-- <div> --> <input :required="truthyExpr"> <!-- <input required=""> --> <input :required="falsyExpr"> <!-- <input> --> <details :open="truthyExpr"> <!-- <details open=""> --> <details :open="falsyExpr"> <!-- <details> --> <input :readonly="truthyExpr"> <!-- <input readonly=""> --> <input :readonly="falsyExpr"> <!-- <input> --> <div :inert="truthyExpr"> <!-- <div inert=""> --> <div :inert="falsyExpr"> <!-- <div> -->

ARIA attributes (aria-*). Strings and numbers pass through ("mixed", "page", 50). Other values coerce to "true" or "false" per the WAI-ARIA spec. Never removed.

<button :aria-expanded="truthyExpr"> <!-- <button aria-expanded="true"> --> <button :aria-expanded="falsyExpr"> <!-- <button aria-expanded="false"> --> <button :aria-pressed="'mixed'"> <!-- <button aria-pressed="mixed"> -->

Stringy enumerated attributes (contenteditable, draggable, spellcheck). Stringify the value. Accepts strings beyond true/false for attributes that support them.

<div :contenteditable="true"> <!-- <div contenteditable="true"> --> <div :contenteditable="false"> <!-- <div contenteditable="false"> --> <div :contenteditable="'plaintext-only'"> <!-- <div contenteditable="plaintext-only"> -->

Property-backed attributes (checked, value, selected). Sync both the DOM property and the HTML attribute.

<input type="checkbox" :checked="true"> <!-- .checked = true, checked="" --> <input type="checkbox" :checked="false"> <!-- .checked = false, attribute removed --> <input :value="'hello'"> <!-- .value = "hello", value="hello" -->

Anything else. Stringify the value. null, undefined, or false remove the attribute.

<a :href="'/profile'"> <!-- <a href="/profile"> --> <a :href="null"> <!-- <a> --> <a :href="false"> <!-- <a> --> <a :href="''"> <!-- <a href=""> -->

Public API

All helpers are exposed under htmx.live.* for use from regular JavaScript (outside hx-live / hx-on expressions):

htmx.live.q('.row') htmx.live.attr('.row', 'hidden', true) htmx.live.take('.tab.active', '.active', '.tab')

htmx.live.refresh() forces a recompute. Use it when an expression reads from a source the observer cannot see (a JS variable, a getter, an external store) and you’ve just mutated it.

window.appState = 'loading'; htmx.live.refresh();

Selector directionals (next, previous, closest) need an anchor and only work inside hx-live / hx-on, not from htmx.live.q.

Configuration

config.live.bindPrefix

Controls the short-form prefix for binding attributes. Defaults to ':' (or disabled automatically if Alpine.js is detected).

ValueEffectExample attribute
undefined (default):attr enabled, unless Alpine detected:hidden, :text, :.active
':':attr short form forced on:hidden, :text, :.active
'' or falsyShort form disabled, only hx-live:attr workshx-live:hidden
'hx:'Custom prefixhx:hidden, hx:text, hx:.active

The long form hx-live:<attr> always works regardless of this setting.

Alpine.js auto-detection

If window.Alpine exists when hx-live initializes and no bindPrefix is configured, the : short form is automatically disabled and a console warning is logged. To resolve:

  • Use the long form hx-live:<attr> (always works)
  • Or explicitly set a non-conflicting prefix:
<!-- Use hx: as short form instead --> <meta name="htmx-config" content='{"live":{"bindPrefix":"hx:"}}'>
  • Or force : if you know what you’re doing:
<meta name="htmx-config" content='{"live":{"bindPrefix":":"}}'>

Manually disabling the short form

If Alpine loads after hx-live (or you want to be explicit), disable it yourself:

<meta name="htmx-config" content='{"live":{"bindPrefix":""}}'>

With bindPrefix: '', use the canonical long form:

<!-- Alpine handles :class, hx-live handles hx-live:text --> <p :class="alpineVar" hx-live:text="q('#name').value"></p>

With bindPrefix: 'hx:':

<!-- Alpine handles :class, hx-live handles hx:text --> <p :class="alpineVar" hx:text="q('#name').value"></p>

Notes

  • Expressions run on any DOM mutation. There is no per-variable tracking. The microtask coalescing keeps this cheap, but expensive expressions should debounce or guard themselves.
  • The DOM is the source of truth. To share state between expressions, use ARIA attributes, data-* attributes (the data proxy makes this ergonomic), or hidden inputs.
  • When using morph swap styles (innerMorph / outerMorph), server responses will overwrite data-* attributes by default. To preserve client-side state during morphs, add a prefix to morphIgnore — e.g. morphIgnore:["data-"] will protect all data-* attributes from being overwritten. Non-morph swaps (innerHTML, outerHTML) replace the DOM entirely, so state should live on an ancestor element that isn’t swapped.
  • Expressions must be safe to run repeatedly. Avoid unconditional fetch() calls. Use debounce or guard on a value change.
  • If your build pipeline strips :-prefixed attributes, use the canonical hx-live:<attr> form instead. Behavior is identical.
  • If using Alpine.js on the same page, hx-live auto-detects it and disables the : short form. See Configuration for details.

See also