The hx-live extension binds DOM state with inline expressions.
Mental Model
Can HTML provide the behavior? ├─ yes → use HTML └─ no Can CSS derive the presentation? ├─ yes → use HTML + CSS └─ no → use hx-live
Installing
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/ext/hx-live.min.js"></script>
Usage
Bind an Attribute
Prefix an attribute with ::
<input value="Ada"> <output :text="'Hello, ' + q('previous input').value"></output> <button :disabled="!q('previous input').value">Continue</button>
Ada → Hello, Ada → Continue enabled empty → Hello, → Continue disabled
See Attributes for every binding target.
Find Elements
Use q() to reach DOM state outside the current element:
q('previous input') // nearby q('#name') // by ID q('.item') // every match q('closest .field') // nearest matching ancestor
Reads use the first match. Writes update every match:
q('.item').aria.busy = true
See q() for its full selector grammar.
Handle an Event
Use hx-on to change state after an event:
<button aria-pressed="false" hx-on:click="aria.pressed = !aria.pressed"> Mute </button>
[aria-pressed="true"] { background: var(--selected); }
Share State
Put shared state on the nearest common ancestor:
<section data-count="0"> <button hx-on:click="data.count++">Add</button> <output :text="data.count"></output> </section>
When the next value depends on the current value:
data.items = items => [...items, next]
See data for typed values, closest lookup, and functional assignments.
Run Async Code
A server response can own its transient behavior.
Request:
<form hx-post="/profile" hx-target="#status" hx-swap="outerHTML"> <input name="name"> <button>Save</button> </form> <output id="status"></output>
Response:
<output id="status" class="notice" hx-on:load=" await timeout('3s') class.leaving = true await forEvent('transitionend', 500) this.remove() "> Saved </output>
.notice { transition: opacity 200ms; } .notice.leaving { opacity: 0; }
timeout() waits before the transition. forEvent() waits for the transition or its fallback timeout.
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 settingconfig.live.bindPrefix.
How each attribute is written (booleans, ARIA, property-backed, generic) is described in Attribute writing rules.
hx-live:<attr>
The long 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').value < 0">Negative balance</p>
:class
String form: set the listed classes.
<input type="number" value="0"> <div :class="q('previous input').value < 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').value < 18, ok: q('previous input').value >= 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').value * q('last input').value"></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 in hx-live bindings and htmx expression scopes. The Public API lists the helpers available to regular JavaScript.
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'); delete attr.disabled; ">Save</button>
q()
q() returns a proxy over a set of elements.
With no matches, state reads return undefined and writes do nothing, including through .closest.
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. They require an expression scope with a current element. For an ancestor lookup that works anywhere, use closest(selector).
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.
State and methods
State writes and method calls apply to every matched element:
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
Local and Closest State
Each state bag has a default scope. data is DOM scoped. Every other bag is element scoped.
| Bag | Default scope | Resolves to |
|---|---|---|
data.* | DOM | the closest element carrying that data-* attribute |
attr.* | element | the element itself |
aria.* | element | the element itself |
class.* | element | the element itself |
The default is the same for a bare expression and for q(...):
data.count // nearest data-count, starting at this element q('.item').data.count // nearest data-count, starting at each .item attr.hidden // the hidden attribute on this element q('.item').attr.hidden // the hidden attribute on each .item
Two bags override the default. local forces element scoping. closest forces DOM scoping.
| Element scope | DOM scope | |
|---|---|---|
| Current expression | local.data.count | closest.attr.hidden |
| Selected elements | q('.item').local.data.count | q('.item').closest.attr.hidden |
A DOM scoped write uses the element itself when no owner exists. A DOM scoped delete does nothing when no owner exists. When several elements share an owner, hx-live updates it once.
closest(selector)
closest also calls as a selector. It returns a query proxy for the nearest ancestor that matches, starting at the element itself.
closest('.card') // nearest .card, including this element closest('.card').data.name // that card's nearest data-name closest('.card').local.data.name // that card's own data-name q('.item').closest('.card') // one entry per distinct card
With no match the result holds zero elements, so reads return undefined and writes do nothing.
attr
Read and write HTML attributes on this element.
attr.hidden // boolean attribute presence attr.hidden = true // add hidden delete attr.hidden // remove hidden attr['aria-expanded'] = false // write aria-expanded="false" attr.contenteditable = false // write contenteditable="false" class.active = true // typed class state attr.value = 'hello' // set the value delete attr['data-x'] // remove data-x
Use bracket notation for names that are not JavaScript identifiers, and for computed names.
checked, selected, and value read the live control state and write both the property and the attribute, so the two never drift apart.
On <input type="number"> and <input type="range">, value reads as a number, and as null when the field is empty. Every other control reads as a string, so <input type="text" value="007"> stays "007".
Numeric attributes (tabindex, colspan, rowspan, maxlength, minlength, size, span, start, rows, cols, width, height, min, max, step, low, high, optimum) read numeric values as numbers. Values such as dates and step="any" remain strings.
Use delete to remove an attribute. Assigning false writes "false".
delete attr['data-x'] attr['data-x'] = null
Use class.* and aria.* for class and ARIA state.
toggle(name, values?)
Toggle or cycle a class, ARIA attribute, 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') // cycle attribute through values toggle('.size', 'sm', 'md', 'lg') // cycle classes (one at a time) toggle('data-open', 'on', '') // cycle: 'on' ↔ absent
Values can also arrive as one |-separated string or one array:
toggle('data-view', 'grid|list|table') toggle('data-view', ['grid', 'list', 'table'])
Cycle values keep their types. For example, '1' remains a string while 1 remains a number.
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
class
class, attr.class, and attr['class'] return the same class state. Read and write membership with boolean properties:
<button class="pending" hx-on:click=" class.pending = false; class.done = true "> Finish </button>
Use bracket notation for class names that are not JavaScript identifiers:
class['is-active'] = true delete class.pending
Set several classes at once with class.assign({ ... }). Truthy values add, falsy values remove, unmentioned classes survive:
<button hx-on:click="class.assign({ active: true, loading: false })">Finish</button>
class extends the native DOMTokenList:
class.add('a', 'b') // add classes class.remove('a', 'b') // remove classes class.toggle('x', force?) // toggle, optional force class.replace('a', 'b') // replace one class with another class.contains('x') // membership class.value // complete class attribute class.length // number of classes class.assign({...}) // group add/remove by truthiness
Use the canonical form when the proxy itself is a value:
[...attr.class] // class names 'x' in attr.class // membership use(attr.class) // function argument
closest.class supports assign, add, remove, toggle, replace, and contains. Aggregate list properties such as value, length, and iteration remain local because each class can have a different closest match.
Native members win on read. Use class.contains('toggle') to read a class whose name collides with a native member. Key writes still change membership, so class.toggle = false removes the class named toggle.
With multiple selected elements, class writes and mutating DOMTokenList methods update every match. Reads and return values use the first match.
Use q() to access another element:
q('#menu').class.open = true
toggle() and take() work on classes by name:
toggle('.active') take('.selected')
aria
Read and write typed ARIA state on this element:
<div aria-busy="false"> <button hx-on:click="q(this).closest.aria.busy = !q(this).closest.aria.busy">Toggle</button> <output :hidden="!q(this).closest.aria.busy">Busy</output> </div>
Use closest.aria.* when you explicitly want the closest match. A write with
no match adds the state to the current element:
aria.busy // aria-busy on this element closest.aria.busy // nearest aria-busy, starting at this q('#form').aria.busy // aria-busy on the selected form q('#form').closest.aria.busy // nearest aria-busy from #form up
Use toggle() and take() for transitions:
<button aria-pressed="false" hx-on:click="toggle('aria-pressed')"> Mute </button> <button aria-sort="ascending" hx-on:click="toggle('aria-sort', 'ascending', 'descending')"> Name </button> <div role="tablist"> <button role="tab" aria-selected="true">One</button> <button role="tab" aria-selected="false" hx-on:click="take('aria-selected')">Two</button> </div>
toggle() flips boolean ARIA between "true" and "false". take() writes "false" on the other elements, then "true" on this element.
Each form uses the same value rules. You can use these values as booleans, numbers, and arrays:
<button aria-busy="false" aria-controls="panel status" hx-on:click=" aria.busy = !aria.busy; aria.controls = [...aria.controls, 'help']; q('#progress').aria.valueNow++ "> Update </button> <section id="panel">...</section> <p id="help">...</p> <output id="status"></output> <div id="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="51"></div>
After one click:
<button aria-busy="true" aria-controls="panel status help">Update</button> <div id="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="52"></div>
Use either form to remove an attribute:
aria.current = null delete aria.current
Value types
hx-live uses the value types from WAI-ARIA 1.2.
Numbers use JSON syntax. For example, aria-valuenow="0.5" reads as 0.5, while aria-valuenow=".5" remains the string ".5".
Boolean
aria-atomicaria-busyaria-checkedaria-currentaria-disabledaria-expandedaria-grabbedaria-haspopuparia-hiddenaria-invalidaria-modalaria-multilinearia-multiselectablearia-pressedaria-readonlyaria-requiredaria-selected
Number
aria-colcountaria-colindexaria-colspanaria-levelaria-posinsetaria-rowcountaria-rowindexaria-rowspanaria-setsizearia-valuemaxaria-valueminaria-valuenow
Token list (string[])
aria-dropeffectaria-relevant
ID reference list (string[])
aria-controlsaria-describedbyaria-flowtoaria-labelledbyaria-owns
All other aria-* attributes remain strings.
You can use aria.* in hx-live, bindings, hx-on, js: attribute values, and hx-trigger filters.
data
Read and write data-* attributes as JSON or plain text.
<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-* holds state shared by a subtree, so data.* walks up to the nearest element that has the attribute. Every other namespace reads this element:
data.count // nearest data-count, starting at this q(this).data.count // data-count on this element only q('#cart').data.count // data-count on the selected cart q('#cart').closest.data.count // nearest data-count from #cart up
On write, hx-live converts booleans, numbers, arrays, and objects to JSON. On read, it converts the JSON back to JavaScript values:
<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.
Writes preserve strings that look like JSON by quoting them:
data.code = '123' // data-code='"123"', reads as '123' data.code = 123 // data-code="123", reads as 123
Use toggle() and take() for transitions:
<button data-active hx-on:click="toggle('data-active')">Toggle details</button> <button data-view="grid" hx-on:click="toggle('data-view', 'grid', 'list')">Change view</button>
Without values, toggle() adds or removes the attribute. Pass values to cycle through them.
Use take() to move state between siblings:
<div> <button data-active="">One</button> <button hx-on:click="take('data-active')" data-active="">Two</button> </div>
Clicking Two removes data-active from One and leaves an empty data-active="" on Two.
The data proxy is enumerable, so object spread, rest destructuring, and Object.keys()/Object.entries() work:
<section data-x="1" data-y="2"> <button data-y="3" hx-post="/cursor" hx-vals="js:{ ...data }"> Send cursor </button> </section>
Here, hx-vals receives { x: 1, y: 3 }.
Delete a value or assign undefined to remove its attribute:
data.count = undefined // remove the nearest data-count delete data.count // remove the nearest data-count delete closest.data.count // remove the nearest data-count delete q('#cart').data.count // remove data-count from the selected cart
data.count = null writes data-count="null". data.count = '' writes an empty data-count="" attribute.
Use dataset when you need raw strings:
this.dataset.count q('#cart').dataset.count
Assign a Function
Assign a function when the next value depends on the current value:
<section data-cart='[{"id":"1"}]'> <button value="2" hx-on:click=" data.cart = cart => [...cart, { id: this.value }] "> Add </button> </section>
[{"id":"1"}] → [{"id":"1"},{"id":"2"}]
The function must return a value synchronously. It also works with DOM properties and attr.*:
q('#panel').hidden = hidden => !hidden attr.hidden = hidden => !hidden
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>
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, then process what was added, so htmx and hx-live attributes in the new content work.
insert('start', '<li>first</li>') // first child insert('end', '<li>last</li>') // last child insert('before', '<hr>') // sibling before insert('after', '<hr>') // sibling after insert('into', '<p>fresh</p>') // replace the children insert('replace', '<p>fresh</p>') // replace the element itself
Parsing stays context sensitive, so a <tr> inserted into a <tbody> lands correctly. After replace the original element is detached.
<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 await forEvent(window, 'resize', '2s') // event on an explicit target
The result is the winning Event, or the original number or interval string when a timeout wins.
Typical use: wait for a CSS transition to finish, with a safety timeout.
<button hx-on:click=" class.add('fade-out'); await forEvent('transitionend', 500); this.remove(); ">Dismiss</button>
nextFrame()
Resolve on the next animation frame.
<button hx-on:click=" class.remove('shake'); await nextFrame(); class.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.
| Attribute | Meaning | Typical 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="aria.expanded = !aria.expanded" aria-expanded="false">Menu</button> </header> <aside :hidden="!q('header button').aria.expanded">...</aside>
Toggle button.
<button hx-on:click="aria.pressed = !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')" aria-selected="true">A</button> <button role="tab" hx-on:click="take('aria-selected')" aria-selected="false">B</button> <button role="tab" hx-on:click="take('aria-selected')" aria-selected="false">C</button> </div>
take('aria-selected') writes "false" on every other 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 bindings:
<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').value"></div>
Advanced Examples
Auto-clearing flash messages
Server response:
HX-Trigger: {"flash":{"target":"#flash", "level":"success", "message":"Saved"}}
Client state:
<style> #flash:empty { display: none; } </style> <div id="flash" data-message="" data-level="" hx-on="flash -> data.message = message; data.level = level; await timeout('3s'); data.message = ''" :text="data.message" :.success="data.level === 'success'" :.error="data.level === 'error'"></div>
How It Works
Re-run triggers
These changes rerun every live expression:
- DOM additions, removals, attribute changes, text changes
inputorchangeevents from any control- completion of an htmx swap (recomputes pause mid-swap, run once at the end)
Multiple synchronous changes coalesce into one recompute.
Self-mutation is safe
Writes made by hx-live do not trigger another recompute.
Slow expressions
After a change, hx-live runs every live expression once. If this takes more than 16ms, hx-live logs one warning:
htmx: hx-live expressions took 18.4ms.
The warning does not stop the expressions.
Coordinating with htmx swaps
Recomputes are deferred between htmx:before:swap and htmx:finally:swap. 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, required, open, readonly, inert, …). Truthy adds the attribute; falsy removes it.
<button :disabled="truthyExpr"> <!-- <button disabled=""> --> <button :disabled="falsyExpr"> <!-- <button> --> <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-*). Values are stringified. null or undefined remove the attribute.
<button :aria-expanded="truthyExpr"> <!-- <button aria-expanded="true"> --> <button :aria-expanded="falsyExpr"> <!-- <button aria-expanded="false"> --> <button :aria-pressed="'mixed'"> <!-- <button aria-pressed="mixed"> -->
String boolean attributes (contenteditable, draggable, spellcheck, writingsuggestions). Parse JSON values and preserve other values as strings.
<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, hidden). Sync both the DOM property and the HTML attribute. hidden preserves the "until-found" state.
<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 or undefined remove the attribute.
<a :href="'/profile'"> <!-- <a href="/profile"> --> <a :href="null"> <!-- <a> --> <a :href="false"> <!-- <a href="false"> --> <a :href="''"> <!-- <a href=""> -->
Public API
Use these helpers from regular JavaScript:
htmx.live.q('.row') htmx.live.$('.row') htmx.live.take('.tab.active', '.active', '.tab') htmx.live.toggle('.tab', 'data-view', 'grid', 'list') htmx.live.debounce(200) htmx.live.forEvent(window, 'resize', '2s') htmx.live.nextFrame() htmx.live.q('.row').local.data.count // force element scoping htmx.live.q('.row').closest.data.count // force DOM scoping htmx.live.q('.row').closest('.card') // nearest matching ancestor
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 a current element, so the string forms do not work from htmx.live.q. The closest(selector) call works anywhere, because it starts at the selected elements:
htmx.live.q('.item').closest('.card').data.name
Configuration
config.live.inputDebounce
Set how long hx-live waits after an input event. Use a number of milliseconds or an interval string. The default is 100ms.
<meta name="htmx-config" content="live.inputDebounce:20ms">
config.live.bindPrefix
Controls the short-form prefix for binding attributes. Defaults to ':' (or disabled automatically if Alpine.js is detected).
| Value | Effect | Example attribute |
|---|---|---|
| undefined (default) | :attr enabled, unless Alpine detected | :hidden, :text, :.active |
':' | :attr short form forced on | :hidden, :text, :.active |
'' or falsy | Short form disabled, only hx-live:attr works | hx-live:hidden |
'hx:' | Custom prefix | hx: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>
config.live.useDollar
Enable $() as an alias for q():
<!-- Because jQuery rocks --> <meta name="htmx-config" content="live.useDollar:true"> <input id="name"> <p :text="$('#name').value"></p>
The alias works in:
hx-live:attrbindingshx-onjs:attributeshx-triggerfilters
Defaults to false.
Notes
- The DOM is the source of truth. To share state between expressions, use ARIA attributes,
data-*attributes (thedataproxy makes this ergonomic), or hidden inputs. - When using morph swap styles (
innerMorph/outerMorph), server responses will overwritedata-*attributes by default. To preserve client-side state during morphs, add a prefix tomorphIgnore— e.g.morphIgnore:["data-"]will protect alldata-*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. Usedebounceor guard on a value change.
Async Code
Use top-level await in hx-live. Do not add an async wrapper.
<!-- Good: htmx handles errors --> <div hx-live="await update()"></div> <!-- Bad: errors go unhandled --> <div hx-live="(async () => { await update() })()"></div>
See Also
hx-on(attribute)- Locality of Behaviour (essay)