How to Add Interactivity to Jekyll with Alpine.js
Alpine.js is a lightweight JavaScript framework perfect for Jekyll sites. Learn how to add dropdowns, tabs, modals, accordions, and more without a build step.
Jekyll produces static HTML — no JavaScript framework, no reactive state, just pages. That is a feature, not a bug. But sometimes you need a little interactivity: a mobile menu toggle, a tab component, an accordion, a modal. Writing vanilla JavaScript for each one is repetitive. React is massive overkill.
Alpine.js is the perfect middle ground. It is a 15kb library that adds reactive behaviour directly in your HTML — no build step, no component files, no bundler required.
What is Alpine.js?
Alpine.js lets you add JavaScript behaviour to HTML elements using special attributes: x-data, x-show, x-bind, x-on, and a handful of others. If you know Tailwind CSS, Alpine has the same philosophy applied to JavaScript — declare behaviour in your markup.
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">I am visible when open is true</div>
</div>
That is it. No component files, no state management, no compilation.
Adding Alpine.js to Jekyll
Option 1: CDN (simplest)
Add Alpine via CDN in your _layouts/default.html before the closing </body>:
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
The defer attribute ensures Alpine loads after the DOM is ready. This is all you need for most use cases.
Option 2: npm install
If you already have a Node.js build pipeline:
npm install alpinejs
Then import in your JS entry point:
import Alpine from "alpinejs";
window.Alpine = Alpine;
Alpine.start();
Practical examples for Jekyll sites
Mobile navigation menu
The most common use case on any Jekyll site:
<nav x-data="{ mobileOpen: false }">
<div class="navbar__inner">
<a href="/" class="navbar__logo">JekyllHub</a>
<!-- Desktop links -->
<ul class="navbar__links">
<li><a href="/themes/">Browse</a></li>
<li><a href="/blog/">Blog</a></li>
</ul>
<!-- Mobile toggle -->
<button @click="mobileOpen = !mobileOpen" :aria-expanded="mobileOpen">
<span x-show="!mobileOpen">☰</span>
<span x-show="mobileOpen">✕</span>
</button>
</div>
<!-- Mobile menu -->
<div x-show="mobileOpen" x-transition @click.away="mobileOpen = false">
<ul>
<li><a href="/themes/">Browse</a></li>
<li><a href="/blog/">Blog</a></li>
</ul>
</div>
</nav>
FAQ accordion
<div class="faq" x-data="{ active: null }">
{% for item in site.data.faq %}
<div class="faq-item">
<button
class="faq-question"
@click="active = active === {{ forloop.index }} ? null : {{ forloop.index }}"
:aria-expanded="active === {{ forloop.index }}"
>
{{ item.question }}
<span x-text="active === {{ forloop.index }} ? '−' : '+'"></span>
</button>
<div
class="faq-answer"
x-show="active === {{ forloop.index }}"
x-transition
>
{{ item.answer }}
</div>
</div>
{% endfor %}
</div>
Tab component
<div x-data="{ tab: 'themes' }">
<!-- Tab buttons -->
<div class="tabs">
<button
@click="tab = 'themes'"
:class="tab === 'themes' ? 'tab--active' : ''"
>Themes</button>
<button
@click="tab = 'posts'"
:class="tab === 'posts' ? 'tab--active' : ''"
>Posts</button>
</div>
<!-- Tab panels -->
<div x-show="tab === 'themes'">
<!-- themes content -->
</div>
<div x-show="tab === 'posts'">
<!-- posts content -->
</div>
</div>
Modal / lightbox
<div x-data="{ open: false, image: '' }">
<!-- Trigger buttons on theme cards -->
{% for theme in site.themes %}
<button
@click="open = true; image = '{{ theme.card_image | relative_url }}'"
>
<img src="{{ theme.card_image | relative_url }}" alt="{{ theme.title }}">
</button>
{% endfor %}
<!-- Modal overlay -->
<div
x-show="open"
x-transition
@click="open = false"
@keydown.escape.window="open = false"
class="modal-overlay"
>
<div @click.stop class="modal-panel">
<button @click="open = false" class="modal-close">✕</button>
<img :src="image" class="modal-image">
</div>
</div>
</div>
Dark mode toggle
<button
x-data
@click="
document.documentElement.classList.toggle('dark');
localStorage.setItem('theme',
document.documentElement.classList.contains('dark') ? 'dark' : 'light'
)
"
aria-label="Toggle dark mode"
>
🌙
</button>
On page load, restore the saved preference:
<script>
if (localStorage.theme === 'dark' ||
(!localStorage.theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
Search input filter
Filter a list of items client-side without a full search library:
<div x-data="{ query: '' }">
<input
x-model="query"
type="search"
placeholder="Filter themes..."
class="search-input"
>
<div class="theme-grid">
{% for theme in site.themes %}
<div
class="theme-card"
x-show="'{{ theme.title | downcase }}'.includes(query.toLowerCase())"
>
<h3>{{ theme.title }}</h3>
</div>
{% endfor %}
</div>
</div>
Alpine.js directives reference
| Directive | What it does |
|---|---|
x-data |
Defines a reactive data scope |
x-show |
Toggles element visibility |
x-if |
Conditionally renders element (removes from DOM) |
x-for |
Loops over an array |
x-model |
Two-way data binding on inputs |
x-text |
Sets element text content |
x-html |
Sets inner HTML |
x-bind or : |
Binds an attribute to a value |
x-on or @ |
Attaches event listeners |
x-transition |
Adds enter/leave CSS transitions |
x-ref |
Gives an element a reference |
x-cloak |
Hides element until Alpine initialises |
Preventing flash of Alpine markup
Before Alpine initialises, x-show elements may flash visible. Prevent this with x-cloak:
<style>[x-cloak] { display: none !important; }</style>
<div x-data="{ open: false }" x-cloak>
<div x-show="open">Hidden until Alpine loads</div>
</div>
Alpine.js vs vanilla JavaScript for Jekyll
For simple toggles and one-off interactions, vanilla JS is fine. Alpine becomes valuable when you have multiple interactive components that would otherwise require repetitive vanilla JS — menus, accordions, tabs, modals, filters. Alpine makes each one a few lines of HTML rather than a script block.
The 15kb cost is worth paying when you have three or more interactive components. If you only need one toggle on your entire site, a three-line vanilla JS function is lighter.
Alpine and Jekyll together give you the interactivity of a framework site with the speed and simplicity of a static one.