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.

FeatureStatusCard
Directives
x-bindequivalentAttribute binding
x-bind (class object)equivalentClass binding
x-bind (class, tabs)equivalentTabs
x-cloakworkaroundCloaking
x-dataequivalentCounter
x-effectequivalentEffects
x-forworkaroundLoops
x-for (filtering)workaroundSearch filtering a list
x-htmlequivalentHTML binding
x-idnoneUnique ids
x-ifworkaroundConditional rendering
x-ignoreequivalentIgnoring a subtree
x-initequivalentInitialisation
x-modelworkaroundTwo-way binding
x-model (filtering)workaroundSearch filtering a list
x-modelablenoneModelable
x-on (modifiers)equivalentEvent modifiers
x-on (.outside)equivalentDropdown with click-outside
x-refequivalentReferences
x-showequivalentDropdown with click-outside
x-teleportnoneTeleport
x-textequivalentCounter
x-transitionworkaroundTransition
Magics
$dataequivalentCounter
$dispatchequivalentDispatching events
$elequivalentThe current element
$idnoneUnique ids
$nextTickequivalentAfter the next render
$refsequivalentReferences
$rootequivalentThe component root
$storeequivalentShared state
$watchworkaroundWatching a value
Globals
Alpine.bindnoneReusable attribute bundles
Alpine.datanoneReusable components
Alpine.storeequivalentShared state
Counterequivalent

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.

Alpine.js
<div x-data="{ count: 0 }">
    <button x-on:click="count++">Increment</button>
    <span x-text="count"></span>
</div>
htmx hx-live
<div data-count="0">
    <button hx-on:click="data.count++">Increment</button>
    <span :text="data.count"></span>
</div>
Class bindingequivalent

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.

Alpine.js
<div x-data="{ pressed: false }">
    <button @click="pressed = ! pressed" :class="{ on: pressed }">Bold</button>
</div>
<style>
    .on { font-weight: bold; }
</style>
htmx hx-live
<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>
Attribute bindingequivalent

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.

Alpine.js
<div x-data="{ placeholderText: 'Type here...' }">
    <input type="text" :placeholder="placeholderText">
</div>
htmx hx-live
<div data-placeholder-text="Type here...">
    <input type="text" :placeholder="data.placeholderText">
</div>
Event modifiersequivalent

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.

Alpine.js
<div x-data="{ message: '' }">
    <input type="text" @keyup.enter="message = 'Enter pressed'">
    <span x-text="message"></span>
</div>
htmx hx-live
<div data-message="">
    <input type="text" hx-on="keyup[key=='Enter'] -> data.message = 'Enter pressed'">
    <span :text="data.message"></span>
</div>
Initialisationequivalent

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.

Alpine.js
<div x-data="{ message: '' }" x-init="message = 'Initialised!'">
    <span x-text="message"></span>
</div>
htmx hx-live
<div data-message="" hx-on:load="data.message = 'Initialised!'">
    <span :text="data.message"></span>
</div>
HTML bindingequivalent

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.

Alpine.js
<div x-data="{ username: '<strong>calebporzio</strong>' }">
    Username: <span x-html="username"></span>
</div>
htmx hx-live
<div data-username="<strong>calebporzio</strong>">
    Username: <span :html="data.username"></span>
</div>
Effectsequivalent

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.

Alpine.js
<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>
htmx hx-live
<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>
Ignoring a subtreeequivalent

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.

Alpine.js
<div x-data="{ label: 'processed' }">
    <span x-text="label"></span>
    <div x-ignore>
        <span x-text="label">untouched</span>
    </div>
</div>
htmx hx-live
<div data-label="processed">
    <span :text="data.label"></span>
    <div hx-ignore>
        <span :text="data.label">untouched</span>
    </div>
</div>
Referencesequivalent

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.

Alpine.js
<div x-data>
    <button @click="$refs.text.remove()">Remove Text</button>

    <span x-ref="text">Hello 👋</span>
</div>
htmx hx-live
<div>
    <button hx-on:click="q('#text').remove()">Remove Text</button>

    <span id="text">Hello 👋</span>
</div>
Two-way bindingworkaround

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.

Alpine.js
<div x-data="{ message: '' }">
    <input type="text" x-model="message">

    <span x-text="message"></span>
</div>
htmx hx-live
<div>
    <input type="text">

    <span :text="q('previous input').value"></span>
</div>
Loopsworkaround

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.

Alpine.js
<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>
htmx hx-live
<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>
Conditional renderingworkaround

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.

Alpine.js
<div x-data="{ open: false }">
    <button @click="open = ! open">Toggle</button>

    <template x-if="open">
        <div>Contents...</div>
    </template>
</div>
htmx hx-live
<div data-open="false">
    <button hx-on:click="data.open = !data.open">Toggle</button>

    <div :hidden="!data.open">Contents...</div>
</div>
Tabsequivalent

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].

Alpine.js
<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>
htmx hx-live
<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>
Transitionworkaround

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.

Alpine.js
<div x-data="{ open: false }">
    <button @click="open = ! open">Toggle</button>

    <div x-show="open" x-transition>
        Hello 👋
    </div>
</div>
htmx hx-live
<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>
Cloakingworkaround

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.

Alpine.js
<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>
htmx hx-live
<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>
Teleportnone
Alpine.js
<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>
No hx-live equivalent

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.

Modelablenone
Alpine.js
<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>
No hx-live equivalent

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.

Unique idsnone
Alpine.js
<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>
No hx-live equivalent

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.

The current elementequivalent

$el is the element the expression sits on. In hx-on and hx-live bindings that is plain this.

Alpine.js
<div x-data>
    <button @click="$el.innerHTML = 'Hello World!'">Replace me with "Hello World!"</button>
</div>
htmx hx-live
<div>
    <button hx-on:click="this.innerHTML = 'Hello World!'">Replace me with "Hello World!"</button>
</div>
Dispatching eventsequivalent

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.

Alpine.js
<div x-data="{ message: '' }" @notify="message = 'Notified!'">
    <button @click="$dispatch('notify')">Notify</button>
    <span x-text="message"></span>
</div>
htmx hx-live
<div data-message="" hx-on:notify="data.message = 'Notified!'">
    <button hx-on:click="trigger('notify')">Notify</button>
    <span :text="data.message"></span>
</div>
The component rootequivalent

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.

Alpine.js
<div x-data data-message="Hello World!">
    <button @click="$el.textContent = $root.dataset.message">Say Hi</button>
</div>
htmx hx-live
<div data-message="Hello World!">
    <button hx-on:click="this.textContent = data.message">Say Hi</button>
</div>
After the next renderequivalent

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.

Alpine.js
<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>
htmx hx-live
<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>
Shared stateequivalent

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.

Alpine.js
<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>
htmx hx-live
<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>
Watching a valueworkaround

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.

Alpine.js
<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>
htmx hx-live
<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>
Reusable componentsnone
Alpine.js
<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>
No hx-live equivalent

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.

Reusable attribute bundlesnone
Alpine.js
<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>
No hx-live equivalent

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.