Alpine.js is usually enough
Most marketing and content sites need three things from JavaScript: something that opens, something that closes, and something that remembers a preference. That is not a framework's worth of work, but it is very easy to reach for one anyway.
Alpine.js is our default for this category, and the reason is not really the file size. It is that Alpine has no opinion about how your application is structured. It attaches behaviour to markup you already have.
Behaviour lives on the element
The whole model is a handful of attributes on the element they affect:
<div x-data="{ open: false }">
<button x-on:click="open = ! open">Menu</button>
<nav x-show="open" x-cloak>
<a href="/blog">Blog</a>
</nav>
</div>
There is no component to register, no build step, no root to mount. Someone reading that markup a year later can see exactly what it does without opening another file — which is the actual argument for Alpine on a content site, where the person maintaining a template is often not the person who wrote it.
A worked example
The dark-mode toggle on this site is Alpine, and it is the whole implementation:
<button type="button"
x-data="{
dark: document.documentElement.classList.contains('dark'),
toggle() {
this.dark = ! this.dark;
document.documentElement.classList.toggle('dark', this.dark);
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
}
}"
x-on:click="toggle()">
<svg x-show="! dark">...</svg>
<svg x-show="dark" x-cloak>...</svg>
</button>
One detail that is easy to get wrong: the class has to be applied before the first paint, or the page flashes the wrong theme while Alpine boots. That belongs in a small inline script in the head, not in Alpine:
<script>
(() => {
const stored = localStorage.getItem('theme');
const dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
})();
</script>
Alpine then reads the class that script already set. Knowing where Alpine should not be used is most of using it well.
Import it properly
Alpine is frequently picked up as a transitive dependency — Livewire bundles it, so it appears on the page without ever being imported. That works until you remove Livewire, at which point every x-data on the site silently stops functioning.
We hit exactly that here. Import it explicitly and the dependency is honest:
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
x-cloak is not optional
Between page load and Alpine initialising, elements controlled by x-show are visible. x-cloak hides them until Alpine takes over, and it only works if you style it:
[x-cloak] {
display: none !important;
}
Miss that one rule and every dropdown on the site flashes open on load. It is the most common Alpine bug we see, and it is a CSS problem rather than a JavaScript one.
Where it stops being the right answer
Alpine is not a replacement for a component framework, and pretending otherwise produces bad code. Once you have shared state across distant parts of a page, meaningful client-side routing, or a data model that needs to stay consistent in several places at once, you want something with a real component tree.
The signal is expression length. When x-data no longer fits comfortably on screen, or you find yourself dispatching custom events to keep two islands of markup in sync, Alpine has stopped being the simpler option.
Until then it is hard to beat: no build step required, no framework runtime to ship, and behaviour that stays where a maintainer will actually look for it.