Every Alpine.js core feature, each with its docs example and a like-for-like htmx 4 hx-live port where one exists. Every demo runs in its own iframe loading only its library. The source under each demo is the exact fragment inside that iframe.
| Feature | Status | Card |
|---|---|---|
| Directives | ||
| x-bind | equivalent | Attribute binding |
| x-bind (class object) | equivalent | Class binding |
| x-bind (class, tabs) | equivalent | Tabs |
| x-cloak | workaround | Cloaking |
| x-data | equivalent | Counter |
| x-effect | equivalent | Effects |
| x-for | workaround | Loops |
| x-for (filtering) | workaround | Search filtering a list |
| x-html | equivalent | HTML binding |
| x-id | none | Unique ids |
| x-if | workaround | Conditional rendering |
| x-ignore | equivalent | Ignoring a subtree |
| x-init | equivalent | Initialisation |
| x-model | workaround | Two-way binding |
| x-model (filtering) | workaround | Search filtering a list |
| x-modelable | none | Modelable |
| x-on (modifiers) | equivalent | Event modifiers |
| x-on (.outside) | equivalent | Dropdown with click-outside |
| x-ref | equivalent | References |
| x-show | equivalent | Dropdown with click-outside |
| x-teleport | none | Teleport |
| x-text | equivalent | Counter |
| x-transition | workaround | Transition |
| Magics | ||
| $data | equivalent | Counter |
| $dispatch | equivalent | Dispatching events |
| $el | equivalent | The current element |
| $id | none | Unique ids |
| $nextTick | equivalent | After the next render |
| $refs | equivalent | References |
| $root | equivalent | The component root |
| $store | equivalent | Shared state |
| $watch | workaround | Watching a value |
| Globals | ||
| Alpine.bind | none | Reusable attribute bundles |
| Alpine.data | none | Reusable components |
| Alpine.store | equivalent | Shared state |
Alpine keeps count in a reactive object declared by x-data. hx-live keeps it in the DOM: data.count reads and writes the closest data-count attribute, JSON round-tripped so the number stays a number. :text re-runs after any DOM mutation, so the span follows the attribute.
<div x-data="{ count: 0 }">
<button x-on:click="count++">Increment</button>
<span x-text="count"></span>
</div>
<div data-count="0">
<button hx-on:click="data.count++">Increment</button>
<span :text="data.count"></span>
</div>
Open state is a data-open attribute on the wrapper, the same place Alpine keeps open in its x-data. hx-live's data.* resolves to the closest ancestor carrying that attribute, so both the button and the panel read the wrapper's value without any extra plumbing.
Alpine attaches .outside to the contents div and gets away with it because its outside handler skips elements that are currently hidden, so the toggle button's own click never counts. htmx's from:outside does the same containment test but has no visibility guard: the toggle button sits outside the contents div, so a listener there would close the panel on the very click that opened it. Attaching it to the component root makes every click inside the component "inside", which is what the reader actually means.
<div x-data="{ open: false }">
<button @click="open = ! open">Toggle</button>
<div x-show="open" @click.outside="open = false">Contents...</div>
</div>
<div data-open="false" hx-on="click from:outside -> data.open = false">
<button hx-on:click="data.open = !data.open">Toggle</button>
<div :hidden="!data.open">Contents...</div>
</div>
Same object-form :class in both. Alpine's condition is a property in x-data; hx-live's is the element's own aria-pressed, read and written through the typed aria bag, which converts the "true"/"false" strings to booleans.
<div x-data="{ pressed: false }">
<button @click="pressed = ! pressed" :class="{ on: pressed }">Bold</button>
</div>
<style>
.on { font-weight: bold; }
</style>
<div>
<button aria-pressed="false"
hx-on:click="aria.pressed = !aria.pressed"
:class="{ on: aria.pressed }">Bold</button>
</div>
<style>
.on { font-weight: bold; }
</style>
The docs example with x-bind:placeholder written as the :placeholder shorthand, which is what hx-live uses too. Alpine reads placeholderText from its component object; hx-live reads data.placeholderText, which maps to the closest data-placeholder-text attribute the same way dataset does.
<div x-data="{ placeholderText: 'Type here...' }">
<input type="text" :placeholder="placeholderText">
</div>
<div data-placeholder-text="Type here...">
<input type="text" :placeholder="data.placeholderText">
</div>
Adapted from the docs' @keyup.enter example. Alpine's dotted modifiers map one-to-one onto hx-on's event grammar: .enter is the bracket filter keyup[key=='Enter'], .prevent is the prevent modifier, and .debounce is delay:500ms, which resets while the event keeps firing.
<div x-data="{ message: '' }">
<input type="text" @keyup.enter="message = 'Enter pressed'">
<span x-text="message"></span>
</div>
<div data-message="">
<input type="text" hx-on="keyup[key=='Enter'] -> data.message = 'Enter pressed'">
<span :text="data.message"></span>
</div>
Adapted from the docs, which log to the console. x-init runs once when Alpine initialises the element; htmx fires a load event when it processes an element, so hx-on:load is the same hook.
<div x-data="{ message: '' }" x-init="message = 'Initialised!'">
<span x-text="message"></span>
</div>
<div data-message="" hx-on:load="data.message = 'Initialised!'">
<span :text="data.message"></span>
</div>
x-html and :html both set innerHTML, with the same warning: never feed either untrusted markup. The hx-live value is a plain string in a data-* attribute; a value that is not valid JSON is returned as-is.
<div x-data="{ username: '<strong>calebporzio</strong>' }">
Username: <span x-html="username"></span>
</div>
<div data-username="<strong>calebporzio</strong>">
Username: <span :html="data.username"></span>
</div>
Adapted from the docs, which log to the console. x-effect tracks the reactive properties it reads and re-runs when one changes. An hx-live expression re-runs after any DOM mutation instead, so it needs no dependency tracking; the result is the same and the expression must simply be cheap and idempotent.
<div x-data="{ label: 'Hello', length: 0 }" x-effect="length = label.length">
<button @click="label += ' World!'">Change Message</button>
<span x-text="length"></span>
</div>
<div data-label="Hello" data-length="0" hx-live="data.length = data.label.length">
<button hx-on:click="data.label += ' World!'">Change Message</button>
<span :text="data.length"></span>
</div>
The docs example, adapted: a sibling span outside the ignored subtree makes the difference visible, the label reads "processed", and the ignored span carries the text "untouched" so there is something to leave alone. x-ignore and htmx's hx-ignore both leave the subtree alone: the inner span keeps its original text while the outer one is bound.
<div x-data="{ label: 'processed' }">
<span x-text="label"></span>
<div x-ignore>
<span x-text="label">untouched</span>
</div>
</div>
<div data-label="processed">
<span :text="data.label"></span>
<div hx-ignore>
<span :text="data.label">untouched</span>
</div>
</div>
Alpine keeps a per-component registry of x-ref names behind $refs. hx-live has no registry; an id, or a directional query such as q('next span'), reaches the same element. Method calls pass through the q() proxy.
<div x-data>
<button @click="$refs.text.remove()">Remove Text</button>
<span x-ref="text">Hello 👋</span>
</div>
<div>
<button hx-on:click="q('#text').remove()">Remove Text</button>
<span id="text">Hello 👋</span>
</div>
x-model keeps an input and a property in sync both ways. hx-live has no two-way binding because the input already is the state: read it with q('previous input').value, and when a handler must write it, assign to .value. hx-live re-evaluates on input events after a short debounce (config.live.inputDebounce, 100 ms by default), so the span lags a keystroke by that much.
<div x-data="{ message: '' }">
<input type="text" x-model="message">
<span x-text="message"></span>
</div>
<div>
<input type="text">
<span :text="q('previous input').value"></span>
</div>
The docs example with a button that appends an item. There is no loop primitive in hx-live: the list is HTML, not an array, so it is rendered by the server or written by hand. Adding to it is insert(), or an htmx request when the server owns the list. This is the largest model difference between the two libraries.
<div x-data="{ colors: ['Red', 'Orange', 'Yellow'] }">
<ul>
<template x-for="color in colors">
<li x-text="color"></li>
</template>
</ul>
<button @click="colors.push('Green')">Add Green</button>
</div>
<div>
<ul>
<li>Red</li>
<li>Orange</li>
<li>Yellow</li>
</ul>
<button hx-on:click="q('previous ul').insert('end', '<li>Green</li>')">Add Green</button>
</div>
This is the one that changes model, not just syntax. hx-live has no loop primitive and no two-way binding: the list is already HTML, so filtering means hiding rows, and the input's own .value is the state. Alpine derives DOM from data; hx-live derives attributes from DOM. A larger or server-owned list would be an htmx request, not client-side filtering. One felt difference: hx-live re-runs input-driven expressions after a short debounce (config.live.inputDebounce, 100 ms by default), while Alpine's x-model updates on every keystroke.
<div
x-data="{
search: '',
items: ['foo', 'bar', 'baz'],
get filteredItems() {
return this.items.filter(
i => i.startsWith(this.search)
)
}
}"
>
<input x-model="search" placeholder="Search...">
<ul>
<template x-for="item in filteredItems" :key="item">
<li x-text="item"></li>
</template>
</ul>
</div>
<div>
<input placeholder="Search...">
<ul hx-live="for (let li of q('li in this')) li.hidden = !li.textContent.startsWith(q('previous input').value)">
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
</div>
x-if adds and removes the element from the DOM. hx-live never removes elements; :hidden keeps it in place and toggles visibility, exactly like x-show. When removal matters, for form submission or focus order, the honest hx-live answer is a server swap.
<div x-data="{ open: false }">
<button @click="open = ! open">Toggle</button>
<template x-if="open">
<div>Contents...</div>
</template>
</div>
<div data-open="false">
<button hx-on:click="data.open = !data.open">Toggle</button>
<div :hidden="!data.open">Contents...</div>
</div>
Closest like-for-like: the selected tab is a data-tab attribute on the wrapper, :.active binds one class, :hidden swaps panels. The more idiomatic hx-live shape is role="tab" buttons with take('aria-selected'), which moves the attribute between peers and drives CSS off [aria-selected=true].
<div x-data="{ tab: 'a' }">
<nav>
<button @click="tab = 'a'" :class="{ active: tab === 'a' }">A</button>
<button @click="tab = 'b'" :class="{ active: tab === 'b' }">B</button>
</nav>
<div x-show="tab === 'a'">Panel A</div>
<div x-show="tab === 'b'">Panel B</div>
</div>
<style>
.active { background: #18181b; color: #fff; border-color: #18181b; }
</style>
<div data-tab="a">
<nav>
<button hx-on:click="data.tab = 'a'" :.active="data.tab === 'a'">A</button>
<button hx-on:click="data.tab = 'b'" :.active="data.tab === 'b'">B</button>
</nav>
<div :hidden="data.tab !== 'a'">Panel A</div>
<div :hidden="data.tab !== 'b'">Panel B</div>
</div>
<style>
.active { background: #18181b; color: #fff; border-color: #18181b; }
</style>
hx-live has no transition directive. Enter and leave are delegated to CSS: the binding only toggles a class, and the stylesheet animates opacity while flipping visibility so the hidden element stays out of the accessibility tree. The <style> is part of the shown source on purpose.
<div x-data="{ open: false }">
<button @click="open = ! open">Toggle</button>
<div x-show="open" x-transition>
Hello 👋
</div>
</div>
<div data-open="false">
<button hx-on:click="data.open = !data.open">Toggle</button>
<div class="fade" :.open="data.open">
Hello 👋
</div>
</div>
<style>
.fade { opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; }
.fade.open { opacity: 1; visibility: visible; }
</style>
Alpine removes x-cloak when it initialises an element, so a [x-cloak] { display: none } rule hides markup until then. hx-live has no cloak directive, but any attribute can be cleared once the element is processed: hx-on:load="attr['hx-cloak'] = null" drops the marker (only null removes a plain attribute; false would be written as the string "false"), and the same CSS rule hides the element until then.
A declarative :hx-cloak="null" binding on the same element looks tidier but does not work in hx-live 4.0.0: it walks an element's attributes in DOM order while applying bindings, and removing hx-cloak mid-walk shifts the list so the sibling :hidden binding is skipped. An event handler runs in its own pass and avoids that.
<div x-data>
<span x-cloak x-show="false">This will not 'blip' onto screen at any point</span>
</div>
<style>
[x-cloak] { display: none !important; }
</style>
<div>
<span hx-cloak :hidden="true" hx-on:load="attr['hx-cloak'] = null">This will not 'blip' onto screen at any point</span>
</div>
<style>
[hx-cloak] { display: none !important; }
</style>
<div x-data="{ open: false }">
<button @click="open = ! open">Toggle Modal</button>
<template x-teleport="body">
<div x-show="open">
Modal contents...
</div>
</template>
</div>
<div>Some other content placed AFTER the modal markup.</div>
x-teleport moves a subtree elsewhere in the document at runtime, usually to escape an ancestor's overflow or stacking context. hx-live has nothing like it. The modern answer is to not need it: <dialog> and the Popover API render in the top layer regardless of where the element sits, and anything else can be placed where it belongs by the server.
<div x-data="{ number: 5 }">
<div x-data="{ count: 0 }" x-modelable="count" x-model="number">
<button @click="count++">Increment</button>
</div>
Number: <span x-text="number"></span>
</div>
x-modelable exposes a child component's property so a parent can x-model it. It only makes sense where components own private state. hx-live has no components and no private state: both elements would read and write the same data-* attribute on their common ancestor, and there would be nothing to expose.
<div x-data>
<div x-id="['text-input']">
<label :for="$id('text-input')">Username</label>
<!-- for="text-input-1" -->
<input type="text" :id="$id('text-input')">
<!-- id="text-input-1" -->
</div>
<div x-id="['text-input']">
<label :for="$id('text-input')">Username</label>
<!-- for="text-input-2" -->
<input type="text" :id="$id('text-input')">
<!-- id="text-input-2" -->
</div>
</div>
The docs example inside an x-data wrapper, which the docs page provides implicitly. x-id and $id generate unique ids on the client so repeated components can pair labels with inputs. hx-live does not generate ids because the markup is already unique when it arrives: the template that renders two copies of a component is the natural place to number them.
$el is the element the expression sits on. In hx-on and hx-live bindings that is plain this.
<div x-data>
<button @click="$el.innerHTML = 'Hello World!'">Replace me with "Hello World!"</button>
</div>
<div>
<button hx-on:click="this.innerHTML = 'Hello World!'">Replace me with "Hello World!"</button>
</div>
Adapted from the docs, which alert. $dispatch and trigger() both fire a bubbling CustomEvent, and both sides listen for it on an ancestor with the same attribute shape: @notify versus hx-on:notify.
<div x-data="{ message: '' }" @notify="message = 'Notified!'">
<button @click="$dispatch('notify')">Notify</button>
<span x-text="message"></span>
</div>
<div data-message="" hx-on:notify="data.message = 'Notified!'">
<button hx-on:click="trigger('notify')">Notify</button>
<span :text="data.message"></span>
</div>
Adapted from the docs, which alert. $root is the element carrying x-data. hx-live's data.* already resolves to the closest ancestor with that attribute, so the root lookup is implicit.
<div x-data data-message="Hello World!">
<button @click="$el.textContent = $root.dataset.message">Say Hi</button>
</div>
<div data-message="Hello World!">
<button hx-on:click="this.textContent = data.message">Say Hi</button>
</div>
Adapted from the docs, which log to the console. Both defer a read until after the pending re-render: the span shows the button's new text, not its old one. Alpine's $nextTick takes a callback and waits for its reactive flush; hx-live's nextFrame() returns a promise for the next animation frame, after its mutation-driven recompute has run, and because hx-on bodies are compiled as async functions you can simply await it inline.
<div x-data="{ title: 'Hello', after: '' }">
<button @click="title = 'Hello World!'; $nextTick(() => { after = $el.innerText })" x-text="title"></button>
<span x-text="after"></span>
</div>
<div data-title="Hello" data-after="">
<button hx-on:click="data.title = 'Hello World!'; await nextFrame(); data.after = this.innerText" :text="data.title"></button>
<span :text="data.after"></span>
</div>
Adapted from the docs' dark-mode store so that two separate components share it. Alpine.store is a global reactive object registered in a script. In hx-live, shared state is a data-* attribute on whatever ancestor the components have in common, up to <body>; every data.dark inside resolves to it.
<div x-data>
<button @click="$store.darkMode.toggle()">Toggle Dark Mode</button>
</div>
<div x-data :class="$store.darkMode.on && 'dark'">
Content
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('darkMode', {
on: false,
toggle() {
this.on = ! this.on
}
})
})
</script>
<style>
.dark { background: #18181b; color: #fff; padding: 4px 8px; }
</style>
<div data-dark="false">
<div>
<button hx-on:click="data.dark = !data.dark">Toggle Dark Mode</button>
</div>
<div :class="{ dark: data.dark }">
Content
</div>
</div>
<style>
.dark { background: #18181b; color: #fff; padding: 4px 8px; }
</style>
Adapted from the docs, which log to the console. $watch runs a callback when one named property changes. hx-live has no per-property watcher; an hx-live expression is an effect that runs whenever anything changes, and it also runs once at load, so the hx-live span reads "open is now false" before any click while Alpine's starts empty.
<div x-data="{ open: false, log: '' }" x-init="$watch('open', value => log = 'open is now ' + value)">
<button @click="open = ! open">Toggle Open</button>
<span x-text="log"></span>
</div>
<div data-open="false" data-log="" hx-live="data.log = 'open is now ' + data.open">
<button hx-on:click="data.open = !data.open">Toggle Open</button>
<span :text="data.log"></span>
</div>
<div x-data="dropdown">
<button @click="toggle">Toggle</button>
<div x-show="open">Contents...</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
toggle() {
this.open = ! this.open
}
}))
})
</script>
Alpine.data registers a component definition that many elements can instantiate. hx-live has no component model on the client: reuse happens where the markup is produced. With a server-rendered stack the reusable unit becomes a template component that emits the data-* attribute and the bindings; the browser only ever sees the expanded result.
<div x-data="{ clicks: 0 }">
<button x-bind="SomeButton">Click</button>
<span x-text="clicks"></span>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.bind('SomeButton', () => ({
type: 'button',
'@click'() {
this.clicks++
},
':disabled'() {
return this.clicks >= 3
},
}))
})
</script>
Adapted from the docs so the bundle does something visible: the button counts to three and then disables itself. Alpine.bind packages attributes and listeners for reuse across elements. hx-live has no equivalent; an attribute bundle is a template concern, and a gsx component that emits hx-on:click and :disabled plays the same role.