Alpine.js brings reactivity to HTML without the complexity of full frameworks. At only 15KB, it's perfect for adding interactivity to server-rendered pages, multi-page applications, or anywhere you need just enough JavaScript. This guide covers building interactive UIs with Alpine.js from a senior developer's perspective.
Why Alpine.js
Alpine offers compelling advantages:
- Minimal Footprint: ~15KB gzipped, loads instantly
- No Build Step: Works directly in HTML
- Declarative: Behavior lives in HTML attributes
- Vue-like Syntax: Familiar patterns, lower complexity
- Perfect for Enhancement: Add to existing server-rendered pages
Getting Started
Installation
CDN (simplest):
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
NPM:
npm install alpinejs
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
Core Directives
x-data: Component State
<!--Define component with state.-->
<div x-data="{ open: false, count: 0, name: '' }">
<p>Count: <span x-text="count"></span></p>
<button @click="count++">Increment</button>
</div>
<!--Nested components have isolated scope.-->
<div x-data="{ outer: 'parent' }">
<div x-data="{ inner: 'child' }">
<span x-text="outer"></span> <!--Works.-->
<span x-text="inner"></span> <!--Works.-->
</div>
</div>
x-on: Event Handling
<!--Click events.-->
<button x-on:click="open = !open">Toggle</button>
<button @click="open = !open">Toggle (shorthand)</button>
<!--Keyboard events.-->
<input @keydown.enter="submitForm()" />
<input @keydown.escape="cancel()" />
<!--Modifiers.-->
<button @click.prevent="handleClick()">Prevent Default</button>
<button @click.stop="handleClick()">Stop Propagation</button>
<button @click.once="runOnce()">Run Once</button>
<button @click.debounce.500ms="search()">Debounced</button>
<!--Multiple events.-->
<input @focus="focused = true" @blur="focused = false" />
x-bind: Dynamic Attributes
<!--Bind attributes.-->
<button x-bind:disabled="isLoading">Submit</button>
<button :disabled="isLoading">Submit (shorthand)</button>
<!--Dynamic classes.-->
<div :class="{ 'active': isActive, 'hidden': !isVisible }"></div>
<div :class="isActive ? 'bg-blue-500' : 'bg-gray-500'"></div>
<!--Dynamic styles.-->
<div :style="{ color: textColor, fontSize: size + 'px' }"></div>
x-model: Two-Way Binding
<div x-data="{ message: '', selected: 'a', checked: false, items: [] }">
<!--Text input.-->
<input type="text" x-model="message" />
<p x-text="message"></p>
<!--Modifiers.-->
<input x-model.lazy="message" /> <!--Sync on blur.-->
<input x-model.number="count" /> <!--Cast to a number.-->
<input x-model.debounce.500ms="search" /> <!--Debounce.-->
<!--Select.-->
<select x-model="selected">
<option value="a">Option A</option>
<option value="b">Option B</option>
</select>
<!--Checkbox.-->
<input type="checkbox" x-model="checked" />
<!--Multiple checkboxes.-->
<input type="checkbox" value="item1" x-model="items" />
<input type="checkbox" value="item2" x-model="items" />
<!--Radio.-->
<input type="radio" value="a" x-model="selected" />
<input type="radio" value="b" x-model="selected" />
</div>
x-show and x-if: Conditional Rendering
<!--x-show: Toggles display CSS (stays in the DOM).-->
<div x-show="isVisible">I'm visible</div>
<!--With transitions.-->
<div x-show="open" x-transition>Fade in/out</div>
<div x-show="open"
x-transition.duration.500ms
x-transition:enter.opacity
x-transition:leave.opacity>
Custom transition
</div>
<!--x-if: Actually removes from the DOM (must use template).-->
<template x-if="showForm">
<form>...</form>
</template>
x-for: Loops
<div x-data="{ items: ['Apple', 'Banana', 'Orange'] }">
<template x-for="(item, index) in items" :key="index">
<div>
<span x-text="index + 1"></span>.
<span x-text="item"></span>
</div>
</template>
</div>
<!--With objects.-->
<template x-for="user in users" :key="user.id">
<div>
<span x-text="user.name"></span>
<span x-text="user.email"></span>
</div>
</template>
Magic Properties
$el: Current Element
<button @click="$el.innerText = 'Clicked!'">Click me</button>
$refs: Element References
<div x-data="{ }">
<input type="text" x-ref="input" />
<button @click="$refs.input.focus()">Focus Input</button>
<button @click="$refs.input.value = ''">Clear</button>
</div>
$watch: Reactive Watcher
<div x-data="{ count: 0 }" x-init="$watch('count', value => console.log('Count is:', value))">
<button @click="count++">Increment</button>
</div>
$dispatch: Custom Events
<!--Child dispatches event.-->
<div x-data @notify="alert($event.detail.message)">
<button @click="$dispatch('notify', { message: 'Hello!' })">
Notify Parent
</button>
</div>
<!--Communication between components.-->
<div x-data @item-selected.window="selectedItem = $event.detail">
Selected: <span x-text="selectedItem"></span>
</div>
<div x-data>
<button @click="$dispatch('item-selected', 'Item 1')">Select Item 1</button>
</div>
$nextTick: After DOM Update
<div x-data="{ items: [] }">
<button @click="
items.push('New Item');
$nextTick(() => {
$refs.list.scrollTop = $refs.list.scrollHeight;
});
">Add Item</button>
<ul x-ref="list" style="max-height: 100px; overflow-y: auto;">
<template x-for="item in items">
<li x-text="item"></li>
</template>
</ul>
</div>
Global State with Stores
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('cart', {
items: [],
add(item) {
this.items.push(item);
},
remove(index) {
this.items.splice(index, 1);
},
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
});
Alpine.store('user', {
name: '',
isLoggedIn: false,
login(name) {
this.name = name;
this.isLoggedIn = true;
},
logout() {
this.name = '';
this.isLoggedIn = false;
}
});
});
</script>
<!--Use store anywhere.-->
<div x-data>
<template x-if="$store.user.isLoggedIn">
<p>Welcome, <span x-text="$store.user.name"></span>!</p>
</template>
<p>Cart items: <span x-text="$store.cart.items.length"></span></p>
<p>Total: $<span x-text="$store.cart.total"></span></p>
<button @click="$store.cart.add({ name: 'Product', price: 9.99 })">
Add to Cart
</button>
</div>
Reusable Components
Extract to Functions
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
}
}));
Alpine.data('counter', (initialCount = 0) => ({
count: initialCount,
increment() {
this.count++;
},
decrement() {
if (this.count > 0) this.count--;
}
}));
});
</script>
<!--Use reusable components.-->
<div x-data="dropdown">
<button @click="toggle()">Menu</button>
<ul x-show="open" @click.outside="close()">
<li>Item 1</li>
<li>Item 2</li>
</ul>
</div>
<div x-data="counter(10)">
<button @click="decrement()">-</button>
<span x-text="count"></span>
<button @click="increment()">+</button>
</div>
Practical Examples
Modal Dialog
<div x-data="{ showModal: false }">
<button @click="showModal = true">Open Modal</button>
<!--Modal backdrop.-->
<div x-show="showModal"
x-transition.opacity
@click="showModal = false"
class="fixed inset-0 bg-black bg-opacity-50 z-40">
</div>
<!--Modal content.-->
<div x-show="showModal"
x-transition
@click.stop
@keydown.escape.window="showModal = false"
class="fixed inset-0 flex items-center justify-center z-50">
<div class="bg-white p-6 rounded-lg max-w-md w-full mx-4">
<h2 class="text-xl font-bold mb-4">Modal Title</h2>
<p>Modal content goes here.</p>
<button @click="showModal = false" class="mt-4 btn">Close</button>
</div>
</div>
</div>
Tabs Component
<div x-data="{ activeTab: 'tab1' }">
<div class="tabs">
<button @click="activeTab = 'tab1'"
:class="{ 'active': activeTab === 'tab1' }">
Tab 1
</button>
<button @click="activeTab = 'tab2'"
:class="{ 'active': activeTab === 'tab2' }">
Tab 2
</button>
<button @click="activeTab = 'tab3'"
:class="{ 'active': activeTab === 'tab3' }">
Tab 3
</button>
</div>
<div class="tab-content">
<div x-show="activeTab === 'tab1'">Content for Tab 1</div>
<div x-show="activeTab === 'tab2'">Content for Tab 2</div>
<div x-show="activeTab === 'tab3'">Content for Tab 3</div>
</div>
</div>
Fetch Data
<div x-data="{
users: [],
loading: false,
error: null,
async fetchUsers() {
this.loading = true;
this.error = null;
try {
const response = await fetch('/api/users');
this.users = await response.json();
} catch (e) {
this.error = 'Failed to load users';
} finally {
this.loading = false;
}
}
}" x-init="fetchUsers()">
<div x-show="loading">Loading...</div>
<div x-show="error" x-text="error" class="text-red-500"></div>
<ul x-show="!loading && !error">
<template x-for="user in users" :key="user.id">
<li x-text="user.name"></li>
</template>
</ul>
<button @click="fetchUsers()" :disabled="loading">Refresh</button>
</div>
Form Validation
<form x-data="{
form: { email: '', password: '' },
errors: {},
submitted: false,
validate() {
this.errors = {};
if (!this.form.email) {
this.errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(this.form.email)) {
this.errors.email = 'Invalid email format';
}
if (!this.form.password) {
this.errors.password = 'Password is required';
} else if (this.form.password.length < 8) {
this.errors.password = 'Password must be at least 8 characters';
}
return Object.keys(this.errors).length === 0;
},
submit() {
if (this.validate()) {
this.submitted = true;
// Submit form...
}
}
}" @submit.prevent="submit()">
<div>
<input type="email" x-model="form.email" placeholder="Email"
:class="{ 'border-red-500': errors.email }">
<p x-show="errors.email" x-text="errors.email" class="text-red-500 text-sm"></p>
</div>
<div>
<input type="password" x-model="form.password" placeholder="Password"
:class="{ 'border-red-500': errors.password }">
<p x-show="errors.password" x-text="errors.password" class="text-red-500 text-sm"></p>
</div>
<button type="submit">Sign In</button>
<p x-show="submitted" class="text-green-500">Form submitted!</p>
</form>
Integration with Backend
Laravel Blade
<div x-data="{
items: @js($items),
deleteItem(id) {
if (confirm('Delete this item?')) {
fetch(`/items/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
}).then(() => {
this.items = this.items.filter(i => i.id !== id);
});
}
}
}">
<template x-for="item in items" :key="item.id">
<div>
<span x-text="item.name"></span>
<button @click="deleteItem(item.id)">Delete</button>
</div>
</template>
</div>
Key Takeaways
- Start simple: x-data, x-on, x-show cover most cases
- Use stores for global state: Avoids prop drilling
- Extract reusable components: Alpine.data() for patterns
- Leverage modifiers: .prevent, .stop, .debounce save code
- Combine with server rendering: Alpine enhances, doesn't replace
- Keep logic in HTML: That's Alpine's strength
Alpine.js is perfect when you need interactivity without the overhead of a full SPA framework—especially for progressively enhancing server-rendered applications.