Alpine.js for the Small Stuff
26 Oct 2026You don’t need a build step for a dropdown. If your page is mostly server-rendered HTML with a handful of interactive bits — a nav menu, a modal, a tab switcher — Alpine.js does the job in a script tag and a few x- attributes. No webpack config, no component tree, no hydration.
Why not just vanilla JS
You could write addEventListener calls by hand. Two problems show up fast. First, you end up hand-managing state (isOpen = true/false) and syncing it to the DOM yourself — toggling classes, setting aria-expanded, closing on outside click. Second, that code doesn’t live next to the markup it controls, so six months later you’re grepping main.js to find which listener touches which button.
Alpine puts the state in the HTML, next to the element it affects. You read the behavior where it happens.
The three directives you’ll use most
x-data— declares a scope of reactive state on an element.x-show/x-if— show or remove an element based on state.x-on(or@click) — bind an event.
That’s most of what you need.
1. A dropdown
<div x-data="{ open: false }" @click.outside="open = false" class="relative">
<button @click="open = !open" :aria-expanded="open">Menu</button>
<div x-show="open" x-transition class="absolute">
<a href="/settings">Settings</a>
<a href="/logout">Log out</a>
</div>
</div>
@click.outside closes it without a manual document listener. x-transition gives you a default fade/scale with zero CSS. :aria-expanded="open" keeps the accessibility attribute in sync with state for free — do this every time you build a disclosure widget.
2. A modal
<div x-data="{ open: false }">
<button @click="open = true">Delete account</button>
<div x-show="open" x-cloak @keydown.escape.window="open = false" class="modal">
<p>Are you sure?</p>
<button @click="open = false">Cancel</button>
</div>
</div>
x-cloak (paired with a [x-cloak]{display:none} CSS rule) stops the modal flashing open before Alpine initializes. @keydown.escape.window catches Escape from anywhere on the page, not just inside the modal.
3. Talking to the server
Alpine isn’t a framework for building apps against an API, but it handles a fetch-and-render fine:
<div x-data="{ items: [], async load() {
const res = await fetch('/api/items');
this.items = await res.json();
} }" x-init="load()">
<template x-for="item in items" :key="item.id">
<li x-text="item.name"></li>
</template>
</div>
x-init runs once on mount. x-for needs a :key — skip it and Alpine reuses DOM nodes incorrectly when the list reorders.
Where it breaks down
Alpine state doesn’t persist across page navigations, and it doesn’t do client-side routing. If you’re building a dashboard with a dozen views and shared state between them, that’s a real SPA, and you want Vue or React with an actual router. Alpine is for progressive enhancement on pages that are otherwise plain HTML — a Rails view, a Hugo template, a Django form. Past a couple hundred lines of x-data, the inline-attribute style stops being readable and you should pull logic into an Alpine.data() component instead of fighting it in the markup.
Verdict
Drop the CDN script tag on any server-rendered page that needs interactivity and reach for x-data before you reach for a bundler. It’s one file, no build, and the behavior lives where you can see it.