Alpine.js: Reactive UI in HTML Markup with x-data, x-show and Transitions
Key takeaways
Alpine.js is a lightweight JavaScript framework that offers reactive and declarative nature of Vue.js at a fraction of the cost. Perfect for adding interactivity to server-rendered HTML.
Introduction
Alpine.js is a rugged, minimal framework for composing JavaScript behavior in your markup. It offers the reactive and declarative nature of big frameworks like Vue or React at a much lower cost.
Created by Caleb Porzio (also creator of Livewire), Alpine.js is especially common in the Laravel ecosystem, where Livewire and the Blade-based starter kits use it for client-side behavior.
Why Alpine.js Matters
The “just right” amount of JavaScript:
- About 15KB gzipped — smaller than React + ReactDOM or Vue
- No build step required — drop in via CDN, works immediately
- Perfect for server-rendered HTML — enhances Laravel Blade, Rails ERB, Django templates
The design idea is that the server stays in charge of rendering and data, and Alpine handles the small bits of state that only matter in the browser: is this dropdown open, which tab is selected, what has the user typed into the filter box. State lives in the HTML attribute where it is used, so there is no separate component tree to keep in sync with the markup the server sent. Under the hood, Alpine uses the same reactivity engine as Vue 3 (@vue/reactivity): x-data objects are wrapped in proxies, and any expression that reads a property re-runs when that property changes.
Why developers choose Alpine.js:
- Laravel/Rails developers — adds interactivity without leaving the server-rendered mindset
- WordPress/PHP developers — modern JavaScript without webpack/babel complexity
- Static site generators — Jekyll, Hugo, Eleventy benefit from no-build interactivity
- Progressively enhancing content — works alongside existing jQuery, doesn’t require full rewrite
Production use cases:
- E-commerce product filters — without full SPA complexity
- Admin dashboards — tabs, modals, dropdowns in server-rendered pages
- Marketing sites — interactive components without React overhead
- WordPress plugins — add modern UX without conflicting with existing code
When to use Alpine.js:
- Building with Laravel, Rails, Django, or WordPress
- Need dropdowns, modals, tabs on mostly static pages
- Want React-like syntax without build tools
- Progressive enhancement of existing server-rendered site
When to use React/Vue instead:
- Building a complex SPA with heavy client-side routing
- Need a large ecosystem of UI libraries
- App logic lives primarily on the client
- Real-time collaborative features (Google Docs-style)
The Problem
Traditional approach with vanilla JavaScript:
<button id="toggle">Toggle</button>
<div id="content" style="display: none;">Content</div>
<script>
const button = document.getElementById('toggle');
const content = document.getElementById('content');
let isOpen = false;
button.addEventListener('click', () => {
isOpen = !isOpen;
content.style.display = isOpen ? 'block' : 'none';
});
</script>
The Solution
With Alpine.js:
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Content</div>
</div>
The Alpine version replaces three things the vanilla version does by hand: finding elements by id, keeping a variable in a closure, and writing the DOM update. Because the state and its uses are in one block of markup, copying this <div> elsewhere on the page gives an independent toggle with no id collisions — something the getElementById version cannot do without renaming.
Installation
CDN (Quick Start)
<!DOCTYPE html>
<html>
<head>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
</head>
<body>
<div x-data="{ count: 0 }">
<button @click="count++">Increment</button>
<span x-text="count"></span>
</div>
</body>
</html>
NPM
npm install alpinejs
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
The defer attribute on the CDN script matters: Alpine must run after the HTML is parsed so it can find the x-data elements, and without defer in the <head> nothing initializes. In production, pin an exact version ([email protected] rather than 3.x.x), so a new release cannot change behavior under you, and add a Subresource Integrity hash if you load from a CDN. With the npm build, register components and plugins (Alpine.data(...), Alpine.plugin(...)) before calling Alpine.start(), and call start() only once per page — calling it twice initializes components twice and duplicates event handlers.
One deployment detail is easy to discover late: Alpine evaluates the expressions in your attributes by constructing functions at runtime, which a strict Content Security Policy without 'unsafe-eval' blocks. Pages with such a CSP need Alpine’s separate CSP build (@alpinejs/csp), which only supports referencing properties and methods defined in Alpine.data, not inline JavaScript expressions.
Core Directives
x-data (State)
<div x-data="{ name: 'John', age: 30 }">
<p x-text="name"></p>
<p x-text="age"></p>
</div>
x-show / x-if (Conditional)
<!-- x-show: toggles display CSS -->
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">I'm visible!</div>
</div>
<!-- x-if: adds/removes from DOM (must be inside an x-data scope that defines user) -->
<template x-if="user">
<div x-text="user.name"></div>
</template>
x-data defines a scope: every Alpine expression inside the element (and its children) can read and write the properties of that object, and nested x-data blocks can see their parents’ properties too. An expression that refers to a name not in any enclosing scope logs Alpine Expression Error: user is not defined in the console, which is what the x-if snippet above does if pasted on its own.
The choice between x-show and x-if is a trade-off. x-show keeps the element in the DOM and toggles display: none, so toggling is cheap, form input inside keeps its value, and the content is present for the initial render (and for search engines). x-if creates and destroys the element, so expensive or rarely shown content costs nothing until needed, and nested components start fresh each time. x-if must be on a <template> tag with a single root element inside; putting it directly on a <div> does nothing.
x-for (Loop)
<div x-data="{ items: ['Apple', 'Banana', 'Orange'] }">
<template x-for="item in items" :key="item">
<li x-text="item"></li>
</template>
</div>
x-for has the same <template> and single-root requirements as x-if. The :key lets Alpine match existing elements to items when the list changes, so reordering moves DOM nodes instead of recreating them; keys must be unique, and using the item text as the key breaks as soon as two items have the same text. For objects, key by an id. Iterating with an index is written x-for="(item, index) in items".
x-on / @ (Events)
<div x-data="{ count: 0 }">
<!-- Long form -->
<button x-on:click="count++">Increment</button>
<!-- Short form -->
<button @click="count++">Increment</button>
<!-- With modifiers -->
<form @submit.prevent="handleSubmit">
<input @keyup.enter="submit">
</form>
</div>
Modifiers keep common event handling out of your JavaScript: .prevent calls preventDefault(), .stop stops propagation, .enter or .escape filter by key, .debounce delays a handler until input pauses (useful for search fields: @input.debounce.300ms="search()"), and .window or .document listen globally, which is how you close a menu on @keydown.escape.window. handleSubmit and submit here are references to methods that must exist in the component’s data; writing @submit.prevent="handleSubmit" without parentheses is allowed, and Alpine calls the method with the event.
x-bind / : (Attributes)
<div x-data="{ isActive: true, color: 'blue' }">
<!-- Long form -->
<div x-bind:class="{ active: isActive }"></div>
<!-- Short form -->
<div :class="{ active: isActive }"></div>
<!-- Multiple attributes -->
<div :class="isActive && 'active'" :style="`color: ${color}`"></div>
</div>
x-model (Two-way Binding)
<div x-data="{ search: '' }">
<input type="text" x-model="search" placeholder="Search...">
<p>Searching for: <span x-text="search"></span></p>
</div>
x-text / x-html
<div x-data="{ message: 'Hello World', html: '<strong>Bold</strong>' }">
<!-- Text content -->
<p x-text="message"></p>
<!-- HTML content -->
<div x-html="html"></div>
</div>
x-html inserts its value as markup, so it must never receive user-controlled content: an <img src=x onerror="..."> in a comment or profile field becomes a stored XSS vulnerability. Use x-text for anything that did not come from a trusted source, and sanitize (for example with DOMPurify) if you must render user HTML. Note also that Alpine directives inside HTML inserted with x-html are initialized, which is another reason to keep it for trusted content only.
x-model works with text inputs, textareas, checkboxes (a boolean, or an array when several checkboxes share a model), radio buttons, and selects. Values from inputs arrive as strings; x-model.number converts them, and x-model.debounce limits how often the state updates while typing.
Practical Examples
Dropdown Menu
<div x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open">Menu</button>
<div x-show="open" x-transition>
<a href="#">Profile</a>
<a href="#">Settings</a>
<a href="#">Logout</a>
</div>
</div>
@click.outside closes the menu when the user clicks anywhere outside the component. It is the Alpine 3 name for what Alpine 2 called .away, which you will still see in older snippets. For an accessible menu, also close it on @keydown.escape.window, set :aria-expanded="open" on the button, and move focus sensibly — Alpine’s official Focus plugin (x-trap) handles focus trapping for modals.
Tabs
<div x-data="{ tab: 'home' }">
<nav>
<button @click="tab = 'home'" :class="{ active: tab === 'home' }">
Home
</button>
<button @click="tab = 'profile'" :class="{ active: tab === 'profile' }">
Profile
</button>
</nav>
<div x-show="tab === 'home'">Home content</div>
<div x-show="tab === 'profile'">Profile content</div>
</div>
Modal
<div x-data="{ open: false }">
<button @click="open = true">Open Modal</button>
<div x-show="open"
x-transition
class="modal">
<div class="modal-content" @click.outside="open = false">
<h2>Modal Title</h2>
<p>Modal content</p>
<button @click="open = false">Close</button>
</div>
</div>
</div>
The outside-click listener belongs on the dialog content, not the overlay. If it sits on the full-screen .modal element, clicks on the dark backdrop count as clicks inside that element and never close it — a common “why won’t my modal close?” question. A modal also usually needs @keydown.escape.window="open = false", focus trapping, and scroll locking on the body, which is where plugins or a small Alpine.data component start to pay off. Wrap the modal in x-cloak so it does not flash on page load (see the FAQ below).
Search Filter
<div x-data="{
search: '',
items: ['Apple', 'Banana', 'Cherry', 'Date'],
get filteredItems() {
return this.items.filter(i =>
i.toLowerCase().includes(this.search.toLowerCase())
);
}
}">
<input type="text" x-model="search" placeholder="Search fruits...">
<template x-for="item in filteredItems" :key="item">
<li x-text="item"></li>
</template>
</div>
The getter is Alpine’s equivalent of a computed property: filteredItems re-evaluates whenever search or items changes, and the x-for updates automatically. Inside the x-data object, this refers to the reactive component data, which is why getters and methods must be regular functions, not arrow functions — an arrow function’s this would be the surrounding scope and this.search would be undefined. Filtering on the client is right for a handful of items already on the page; for hundreds of rows or data that lives on the server, filter with a debounced request instead (section 6), or let the server re-render the list with a tool like htmx.
Advanced Features
Alpine.data (Component)
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
toggle() {
this.open = !this.open;
}
}));
});
</script>
<div x-data="dropdown">
<button @click="toggle">Toggle</button>
<div x-show="open">Dropdown content</div>
</div>
Inline x-data objects are fine for a few properties, but once a component has methods, reuse, or more than a couple of lines of logic, Alpine.data is the better home: the logic lives in a JavaScript file where it can be linted, tested, and shared, and the markup stays readable. The alpine:init event fires after Alpine loads but before it initializes the page, which is the only moment registration works; registering later leaves x-data="dropdown" pointing at nothing and logs dropdown is not defined. A component can also define init(), which Alpine calls automatically, and destroy() for cleanup such as removing global listeners.
$watch (Reactive)
<div x-data="{ count: 0 }" x-init="$watch('count', value => console.log('Count:', value))">
<button @click="count++">Increment</button>
</div>
$refs (DOM Access)
<div x-data="{}">
<input x-ref="input" type="text">
<button @click="$refs.input.focus()">Focus Input</button>
</div>
These “magic” properties ($watch, $refs, $el, $dispatch, $nextTick, $store) cover what components need beyond plain state. $watch is useful for side effects such as saving to localStorage when a value changes, but reach for a getter first when you only need a derived value. $refs is scoped to the current component, so x-ref names only need to be unique within it. For communication between separate components, $dispatch('notify', { message }) fires a DOM event that another component can catch with @notify.window, and Alpine.store() provides shared global state — the supported alternative to the global variable warned against below.
Transitions
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<!-- Simple transition -->
<div x-show="open" x-transition>
Fade in/out
</div>
<!-- Custom transition -->
<div x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-90"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-90">
Custom animation
</div>
</div>
Plain x-transition applies a default fade and slight scale, and accepts modifiers such as x-transition.duration.200ms or x-transition.opacity. The long form assigns classes at each stage — the class names here are Tailwind utilities, so without Tailwind (or equivalent CSS) nothing animates. Transitions only work with x-show, or with x-if on the element inside the template; when the leave animation seems to be skipped, the usual cause is that the element is being removed by something else, such as a parent’s x-if, before its own transition can run. Users who enable “reduce motion” in their OS settings can be respected with a prefers-reduced-motion media query in your CSS.
Ajax Example
<div x-data="{
users: [],
loading: false,
async fetchUsers() {
this.loading = true;
const res = await fetch('/api/users');
this.users = await res.json();
this.loading = false;
}
}" x-init="fetchUsers()">
<div x-show="loading">Loading...</div>
<template x-for="user in users" :key="user.id">
<div x-text="user.name"></div>
</template>
</div>
This works for the happy path, and it is worth seeing what it leaves out, because the gaps are the same in every framework. If the request fails or returns an error status, res.json() may throw, loading stays true forever, and the user sees “Loading…” indefinitely. Wrap the body in try/finally so loading is always reset, check res.ok, and keep an error property to display. Fetching on page load also means the data arrives after the first paint; if the server already renders the page, it can often render this list directly into the HTML (or pass it as JSON in the x-data attribute), which is faster and works without JavaScript. That is the main architectural question with Alpine: use it for behavior, and let the server keep rendering data where it can.
Framework Comparison
| Feature | Alpine.js | React | Vue |
|---|---|---|---|
| Size | 15KB | 40KB | 34KB |
| Learning Curve | Easy | Medium | Medium |
| Use Case | Sprinkles | SPA | SPA |
| Build Required | No | Yes | Optional |
The comparison is less about size than about where state and rendering live. With React or Vue, the client owns the UI: data comes as JSON, and components render all of it. With Alpine, the server owns the UI, and Alpine adds local, ephemeral state on top. Alpine starts to strain when many components need to share and update the same data, when the markup attributes grow into long JavaScript expressions that are hard to test, or when most of the page is re-rendered from client-side data — those are the signs a component framework would be simpler. For the common case of a server-rendered site that needs menus, tabs, modals, and a few filters, Alpine keeps the whole stack smaller. Paired with htmx for server round trips, it covers a surprising share of what teams otherwise build an SPA for.
When Alpine stops being the right tool
Alpine works best when the server already renders the page and you need small islands of behavior: dropdowns, tabs, modals, form toggles. Its state lives in the HTML attributes where it is used, which is exactly what makes it easy to read at that scale.
That same property is the limit. Once several distant parts of the page need the same data, you end up with Alpine.store holding most of the state and long expressions spread across attributes, which are harder to test and refactor than code in a module. When the page is rendered mostly on the client, has its own routing, or needs to render large lists that change often, a component framework (Vue, Svelte, React) fits better. Also check your Content Security Policy early: the standard build evaluates attribute expressions at runtime, so a strict CSP without unsafe-eval requires Alpine’s CSP build and its more restricted syntax.
Next Steps:
Resources:
Frequently Asked Questions (FAQ)
Q. Why do my x-show elements flash on the page before Alpine hides them?
A. The browser renders the HTML before Alpine initializes, so elements that should start hidden are briefly visible. Add the x-cloak attribute to them and the CSS rule [x-cloak] { display: none !important; }; Alpine removes the attribute once the component is initialized. If the flash still appears, check that the rule is in a stylesheet loaded before the page renders.
Related Articles
- Qwik and Resumability
- SolidJS Reactivity in Practice
- HTMX: Server-Rendered Interactivity with hx-get, hx-swap, Triggers and Out-of-Band Swaps