Alpine.js
Purpose
Add interactivity to HTML pages using Alpine.js directives — a lightweight, declarative framework that works directly in the DOM without a build step.
Agent Protocol
Trigger
Exact user phrases: "Alpine.js setup", "Alpine component", "Alpine x-data", "Alpine x-init", "Alpine store", "Alpine project", "Alpine directive", "alpine reactive".
Input Context
Before activating, verify:
- Alpine.js is included (CDN script or npm).
- Whether the project uses Alpine + backend (Laravel, Django, etc.) or standalone.
Output Artifact
No file output. Produces HTML snippets with Alpine directives as text.
Response Format
HTML with Alpine directives:
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Content</div>
</div>
No preamble. No postamble. No explanations. Compress output — why use many token when few do trick.
Completion Criteria
- Component state defined with x-data on root element.
- Event handlers use x-on or @ shorthand.
- Dynamic attributes use x-bind or : shorthand.
- Conditional rendering uses x-show or x-if.
- Lists use x-for with unique :key.
- Shared state uses Alpine.store().
- Side effects use x-init or $watch.
- No external build step required (CDN-compatible).
Max Response Length
~4096 tokens.
Architecture Decision Trees
State Management Decision
How many components need the state?
Single component -> x-data with inline object
Multiple sibling components -> Alpine.store()
Nested components -> x-data on parent with scope inheritance
What type of state?
UI toggles (open/close) -> x-data { open: false }
Form data -> x-data with x-model binding
Data from server -> x-data + fetch() in x-init
Computed/derived -> $watch or x-effect for reactions
Rendering Decision
Should the element be visible or removed?
Frequently toggled -> x-show (CSS display toggle, faster)
Rarely toggled or heavy content -> x-if (DOM add/remove)
Initial render conditional -> x-init to set state, then x-show
Loop rendering:
Array of items -> x-for on template tag
Need stable identity -> :key binding (always add this)
Plugin Selection Guide
Need:
Collapse/expand animation -> @alpinejs/collapse
Intersection observer -> @alpinejs/intersect
Persist to localStorage -> @alpinejs/persist
Focus trap (modals) -> @alpinejs/focus
Smooth DOM morphing -> @alpinejs/morph
Input masks -> @alpinejs/mask
Tooltips -> alpinejs-tooltip
Workflow
Step 1: Install Alpine.js
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
Step 2: Basic Component
<div x-data="{ count: 0 }">
<button @click="count++">Increment</button>
<span x-text="count"></span>
</div>
Step 3: Forms and Binding
<div x-data="{ name: '', email: '' }">
<input x-model="name" placeholder="Name">
<input x-model="email" placeholder="Email" type="email">
<button @click="submit()" :disabled="!name || !email">Submit</button>
<div x-show="name">
<p>Hello, <span x-text="name"></span>!</p>
</div>
</div>
Step 4: Shared Store
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('user', {
name: 'Guest',
loggedIn: false,
login(name) { this.name = name; this.loggedIn = true },
logout() { this.name = 'Guest'; this.loggedIn = false },
})
})
</script>
<div x-data>
<template x-if="$store.user.loggedIn">
<p>Welcome, <span x-text="$store.user.name"></span></p>
</template>
</div>
Step 5: Fetching Data
<div x-data="{
posts: [],
loading: false,
async fetchPosts() {
this.loading = true
this.posts = await (await fetch('/api/posts')).json()
this.loading = false
}
}" x-init="fetchPosts()">
<div x-show="loading">Loading...</div>
<template x-for="post in posts" :key="post.id">
<div>
<h3 x-text="post.title"></h3>
<p x-text="post.body"></p>
</div>
</template>
</div>
Step 6: State Management Patterns
Signal-like pattern with $watch:
<div x-data="{ count: 0, doubled: 0 }" x-init="$watch('count', val => doubled = val * 2)">
<button @click="count++">+1</button>
<p x-text="`Count: ${count}, Doubled: ${doubled}`"></p>
</div>
Composable-like extracted component (magic $data):
<div x-data="counter()">
<button @click="increment">+1</button>
<span x-text="count"></span>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('counter', () => ({
count: 0,
increment() { this.count++ },
reset() { this.count = 0 },
}))
})
</script>
Persisted state across page loads:
<div x-data x-init="$store.theme = $persist('light').as('theme')">
<button @click="$store.theme = $store.theme === 'light' ? 'dark' : 'light'">
Toggle theme
</button>
</div>
Step 7: Modals and Tabs
<div x-data="{ activeTab: 'info' }">
<button @click="activeTab = 'info'" :class="{ active: activeTab === 'info' }">Info</button>
<button @click="activeTab = 'settings'" :class="{ active: activeTab === 'settings' }">Settings</button>
<div x-show="activeTab === 'info'">Info content</div>
<div x-show="activeTab === 'settings'">Settings content</div>
</div>
Step 8: Transitions
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-90"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-90">
Animated content
</div>
</div>
Step 9: x-teleport for Portals
<div x-data="{ open: false }">
<button @click="open = true">Open Modal</button>
<template x-teleport="body">
<div x-show="open" @click.outside="open = false">
<div class="modal-backdrop" @click="open = false"></div>
<div class="modal-content">
<h2>Modal Title</h2>
<p>Portal-rendered content</p>
<button @click="open = false">Close</button>
</div>
</div>
</template>
</div>
Component Architecture
Alpine.js Decision Tree
Does the component need:
Local state only? -> x-data with inline object
Shared state across components? -> Alpine.store()
AJAX data loading? -> x-init with fetch()
User input binding? -> x-model
Conditional display? -> x-show (toggle) or x-if (DOM add/remove)
Loops? -> x-for with :key
Dynamic styles/classes? -> :style or :class binding
Side effects on data change? -> $watch or x-effect
Component Patterns
Alpine.data() reusable components:
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
toggle() { this.open = !this.open },
close() { this.open = false },
}))
})
</script>
<div x-data="dropdown">
<button @click="toggle">Toggle</button>
<div x-show="open" @click.outside="close">
Dropdown content
</div>
</div>
Alpine.data() with props via $el:
<div x-data="tooltip({ text: 'Hello World' })">
<span @mouseenter="show" @mouseleave="hide">Hover me</span>
<div x-show="visible" x-text="text"></div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('tooltip', (config) => ({
text: config.text,
visible: false,
show() { this.visible = true },
hide() { this.visible = false },
}))
})
</script>
Common Pitfalls
- Forgetting x-data on parent: Child elements can't access state without a parent x-data scope.
- Mixing Alpine with jQuery DOM manipulation: Alpine manages the DOM — direct manipulation breaks reactivity.
- Complex expressions in x-data: Extract complex logic into component methods.
- Overusing x-if instead of x-show: x-show is more performant for frequent toggles.
- Not using :key in x-for: Without keys, Alpine may reuse DOM elements incorrectly.
- Missing alpine:init for stores: Alpine stores must be registered before components initialize.
- Async data in x-data constructor: Use x-init or async methods, not the x-data expression itself.
- Leaking event listeners: x-on has no built-in cleanup — use @click.window sparingly.
- x-model on non-input elements: x-model only works on form inputs (input, select, textarea).
- Modifier stacking order: @click.shift.prevent vs @click.prevent.shift — order matters.
Best Practices
- State belongs in x-data, not in external JS files.
- Use Alpine.store() for global state shared across components.
- Use x-model for form inputs (two-way binding).
- Use x-show for toggles, x-if for conditional DOM removal.
- Use @click.prevent or .window modifiers for event control.
- Fetch side effects in x-init or via $nextTick.
- Keep x-data expressions simple; extract complex logic into methods.
- Use x-transition for smooth enter/leave animations.
- Register reusable component logic via Alpine.data().
- Debounce expensive operations with x-debounce modifier.
Compared With
| Aspect | Alpine.js | Vue | React | htmx |
|---|---|---|---|---|
| Bundle size | ~10KB | ~33KB | ~42KB | ~14KB |
| Build step | None | Optional | Required | None |
| State management | x-data/store | data/props | useState | Server |
| Templating | Directives | Vue templates | JSX | Server HTML |
| Learning curve | Low | Medium | High | Very Low |
| Best for | Server-rendered + interactivity | SPAs | SPAs/complex UIs | Hypermedia apps |
Alpine.js vs htmx
Alpine excels at client-side interactivity (modals, tabs, toggles). htmx excels at server-driven HTML replacement. They complement each other well — use htmx for form submissions and navigation, Alpine for UI interactions.
Alpine.js vs Vue
Both use similar directive syntax (x- vs v-), but Alpine has no build step, no virtual DOM, and no component system beyond DOM scoping. Vue is appropriate when the entire app is an SPA; Alpine shines when the backend renders most HTML.
Performance
- Alpine.js is ~10KB min+gzip — negligible bundle impact.
- No virtual DOM — Alpine mutates real DOM directly.
- x-show uses CSS display:none (no DOM removal, faster toggles).
- x-if removes/adds DOM nodes (useful for conditional heavy content).
- No build step means no compile time — just load and use.
- Fine-grained reactivity — only updates change-dependent DOM parts.
- Alpine.store() is persistent across page navigations if page is SPA-like.
- x-teleport moves DOM nodes, doesn't clone — minimal overhead.
- x-model.lazy defers sync to change event instead of input event.
- x-cloak prevents flash of unstyled content before initialization.
Testing Strategies
Manual Testing
Alpine components can be tested by rendering HTML with directives and asserting DOM behavior:
<!-- Test component in isolation -->
<div x-data="{ count: 0 }">
<button @click="count++" x-test="increment-btn">+1</button>
<span x-text="count" x-test="count-display"></span>
</div>
Cypress Testing
cy.visit('/page-with-alpine')
cy.get('button').click()
cy.contains('span', '1').should('exist')
Playwright Testing
await page.goto('/page-with-alpine')
await page.click('button')
const text = await page.textContent('span')
expect(text).toBe('1')
Key Testing Practices
- Test Alpine components via the DOM, not the JS — Alpine is DOM-driven.
- Wait for Alpine initialization:
document.addEventListener('alpine:init', ...)fires before components render. - Use
Alpine.deferLoadingAlpine()for controlling when Alpine boots in tests. - Test stores independently by calling
Alpine.store()methods directly.
Migration Patterns
From jQuery to Alpine
| jQuery Pattern | Alpine Equivalent |
|---|---|
$(el).hide() |
x-show="false" |
$(el).text('hi') |
x-text="'hi'" |
$(el).on('click', fn) |
@click="fn" |
$.ajax() |
fetch() in x-init |
$(el).addClass('a') |
:class="{ a: condition }" |
| Global state via vars | Alpine.store() |
| DOM traversal | x-ref + $refs |
Migration strategy: Incrementally replace jQuery DOM manipulation with Alpine directives on the same elements. Alpine and jQuery can coexist temporarily — Alpine manages its own x-data scopes, jQuery manipulates outside them.
From Vue 2 Options API to Alpine
If migrating a simple Vue app that doesn't need SPA features:
data()→x-dataobjectcomputed→$watchor expression in templatemethods→ methods in Alpine.data()v-model→x-modelv-if/v-show→x-if/x-showVuex→Alpine.store()
Tooling
- Alpine.js DevTools browser extension — inspect components, state, stores.
@alpinejs/collapse— collapse/expand animation plugin.@alpinejs/intersect— intersection observer plugin.@alpinejs/persist— persist state to localStorage.@alpinejs/focus— focus trap plugin for modals.@alpinejs/morph— smooth DOM morphing plugin.@alpinejs/mask— input mask plugin.alpine-ajax— AJAX helper similar to htmx for Alpine.alpinejs-tooltip— tooltip plugin.alpinejs-click-away— click away utility.
Build and Bundle Considerations
- Alpine works via CDN — no build step required.
- For npm installs:
npm install alpinejsthenimport Alpine from 'alpinejs'. - Tree-shaking: Alpine's npm package supports tree-shaking for plugins not imported.
- When bundled via webpack/Vite, Alpine adds ~10KB gzipped.
- Plugins each add 1-3KB — only load what you use.
- No SSR considerations — Alpine is purely client-side.
Rules
- State belongs in x-data, not in external JS files.
- Use Alpine.store() for global state shared across components.
- Use x-model for form inputs (two-way binding).
- Use x-show for toggles, x-if for conditional DOM removal.
- Use @click.prevent or .window modifiers for event control.
- Fetch side effects in x-init or via $nextTick.
- Avoid mixing Alpine with jQuery-like DOM manipulation — let Alpine manage the DOM.
- Keep x-data expressions simple; extract complex logic into methods.
References
- references/alpine-patterns.md — Alpine.js Patterns & Best Practices
- references/alpine-setup.md — Alpine.js Setup Guide
- references/alpinejs-advanced.md — Alpine.js Advanced Patterns
- references/alpinejs-deployment.md — Alpine.js Deployment
- references/alpinejs-fundamentals.md — Alpinejs Fundamentals
- references/alpinejs-testing.md — Alpine.js Testing Reference
- references/alpinejs-component-patterns.md — Alpine.js Component Patterns
- references/alpinejs-state-management.md — Alpine.js State Management Reference
Handoff
No artifact produced. Next skill: alpine-laravel (if Laravel backend) or frontend-testing. Carry forward: x-data state pattern, x-model binding, store pattern.
Implementation Patterns
Factory Pattern for Module Creation
function createModule<T>(config: ModuleConfig): T { const dependencies = initializeDependencies(config); const module = new Module(dependencies); module.hooks.onInit(); return module as T; }
Builder Pattern for Complex Configuration
class ConfigBuilder { private config: AppConfig = new AppConfig(); withDatabase(url: string): ConfigBuilder { ... } withCache(ttl: number): ConfigBuilder { ... } withLogging(level: string): ConfigBuilder { ... } build(): AppConfig { return this.config; } }
Production Considerations
Deployment Checklist
- Production build with optimizations enabled
- Environment variables configured per environment
- Health check endpoint responds correctly
- Error tracking and monitoring integrated
- Logging level configured (not debug in production)
- Resource limits configured
- Database migrations applied
- Static assets built and served from CDN or cache
- Feature flags toggled appropriately
- Rollback plan documented and tested
Monitoring and Alerting
| Metric | Threshold | Severity | Action |
|---|---|---|---|
| Error rate | > 1% | Critical | Rollback or fix |
| p95 latency | > 500ms | Warning | Profile and optimize |
| Uptime | < 99.9% | Critical | Investigate infrastructure |
| Memory usage | > 80% | Warning | Check for leaks |
| CPU usage | > 80% | Warning | Scale up or optimize |
Anti-Patterns
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
| Complex logic in Alpine expressions | Unreadable, untestable | Extract to JavaScript functions, reference by name |
| Deeply nested x-data | State becomes tangled | Use $store or flat component state |
| Direct DOM manipulation alongside Alpine | Causes hydration conflicts | Always use Alpine directives (x-text, x-bind, x-model) |
| Ignoring x-cloak for dynamic content | FOUC on page load | Always add x-cloak to root Alpine elements |
| Overusing x-effect instead of computed | Effects run on every dependency change | Use x-data getters for computed values |
Performance Optimization
- x-if vs x-show: Use
x-iffor content that toggles rarely (tabs, modals) - it removes DOM nodes. Usex-showfor frequent toggles (dropdowns, tooltips) - it uses CSS display toggle. x-if is more expensive on first render but cheaper on memory. - Debounce expensive operations: Attach
.debouncemodifier to inputs that trigger API calls or complex computations:@input.debounce.300ms="search". Use.throttlefor scroll/resize handlers. - Lazy-load Alpine components: Split pages into multiple
x-datascopes rather than one large scope. Each scope initializes independently. Use Intersection Observer withx-intersectto init components when visible. - Minimal reactivity: Alpine tracks property access inside x-data. Use
Object.freeze()for static data to prevent observation overhead. Avoid deeply nested reactive objects.
Security Considerations
- CSP mode: Always prefer the CSP-compatible build (
alpinejs@3/dist/csp.min.js) in production. It restricts expressions tox-dataattributes only, preventing injection of arbitrary JS viax-textorx-html. - Sanitize x-html input:
x-htmlinserts raw HTML. Never bind user-generated content tox-html. Usex-textfor user content. If HTML rendering is required, sanitize through DOMPurify first. - Avoid inline event handlers with user input:
@click="handleUserInput(someUserValue)"can be exploited ifsomeUserValuecontains expressions. Validate and escape user data before passing into Alpine expressions. - Protect $store from mutation: Use
Object.freeze()or proxy patterns to prevent accidental store corruption from third-party scripts. Validate store mutations in production.