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.
Managing global state with Alpine.store
Individual components with x-data have their own isolated scope. When two components need to share state — a cart item count in the nav and a cart sidebar, for example — use Alpine.store:
document.addEventListener('alpine:init', () => {
Alpine.store('cart', {
items: [],
count: 0,
add(item) {
this.items.push(item);
this.count++;
},
remove(id) {
this.items = this.items.filter(i => i.id !== id);
this.count = this.items.length;
}
});
});
Reference the store in any component:
<!-- Nav badge -->
<span x-text="$store.cart.count" x-show="$store.cart.count > 0" class="badge">
</span>
<!-- Cart sidebar -->
<div x-data>
<template x-for="item in $store.cart.items" :key="item.id">
<div x-text="item.name"></div>
</template>
</div>
For Jekyll theme marketplaces, this pattern handles a “saved themes” or “bookmarks” feature elegantly — the count shows in the nav, the full list is in a sidebar panel, and both stay in sync without any event juggling.
Alpine.js with Jekyll data files
One of the most productive patterns in a Jekyll + Alpine setup is combining Jekyll’s data files with Alpine’s reactive filtering. Build the data with Jekyll at compile time, then let Alpine filter it in the browser.
<div x-data="{ search: '', category: 'all' }">
<!-- Controls -->
<input x-model="search" type="search" placeholder="Search themes...">
<div class="category-tabs">
<button @click="category = 'all'" :class="category === 'all' ? 'active' : ''">All</button>
<button @click="category = 'blog'" :class="category === 'blog' ? 'active' : ''">Blog</button>
<button @click="category = 'portfolio'" :class="category === 'portfolio' ? 'active' : ''">Portfolio</button>
<button @click="category = 'docs'" :class="category === 'docs' ? 'active' : ''">Docs</button>
</div>
<!-- Theme grid with Alpine filtering -->
<div class="theme-grid">
{% for theme in site.themes %}
<div class="theme-card"
x-show="
(category === 'all' || category === '{{ theme.category | downcase }}')
&& ('{{ theme.title | downcase }}'.includes(search.toLowerCase())
|| '{{ theme.description | downcase }}'.includes(search.toLowerCase()))
">
<h3>{{ theme.title }}</h3>
<p>{{ theme.description }}</p>
</div>
{% endfor %}
</div>
<!-- No results message -->
<p x-show="document.querySelectorAll('.theme-card[style*=\'display: none\']').length === document.querySelectorAll('.theme-card').length"
class="no-results">No themes match your search.</p>
</div>
Jekyll renders all the theme cards at build time. Alpine handles the filtering entirely client-side — no API call, no page reload, instant results.
Transitions and animations
Alpine’s x-transition directive makes showing and hiding elements feel polished. The simplest usage adds default CSS transitions:
<div x-show="open" x-transition>
Content with smooth fade in/out
</div>
Customise with duration and type:
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2">
Dropdown content
</div>
These class names work directly with Tailwind CSS. If you are using custom CSS instead of Tailwind, define the transition classes in your Sass:
.enter-start { opacity: 0; transform: translateY(8px); }
.enter-end { opacity: 1; transform: translateY(0); }
.leave-start { opacity: 1; transform: translateY(0); }
.leave-end { opacity: 0; transform: translateY(8px); }
Lazy-loaded Alpine components
Alpine initialises immediately on page load. For components that appear only in modals or below the fold, this is wasteful. Defer initialisation with Alpine.data():
// assets/js/components.js
document.addEventListener('alpine:init', () => {
Alpine.data('searchModal', () => ({
query: '',
results: [],
loading: false,
async search() {
if (this.query.length < 2) { this.results = []; return; }
this.loading = true;
const response = await fetch(`/search.json?q=${this.query}`);
this.results = await response.json();
this.loading = false;
}
}));
});
Use it in your HTML:
<div x-data="searchModal">
<input x-model="query" @input.debounce.300ms="search" type="search">
<div x-show="loading">Searching...</div>
<template x-for="result in results" :key="result.url">
<a :href="result.url" x-text="result.title"></a>
</template>
</div>
The .debounce.300ms modifier on the input event prevents a search request on every keystroke — it waits 300ms after the last keypress, which is exactly the UX you want for search.
When not to use Alpine.js
Alpine is a tool for adding interactivity to server-rendered or static HTML. It is not a replacement for a full framework when you need one. Do not reach for Alpine when:
You need complex state management — if state needs to be persisted, synchronised with a server, or shared across many pages, a proper state management solution is more appropriate.
You are building a complex single-page application — Alpine is for enhancing HTML, not building SPAs. If the user experience requires page transitions, client-side routing, or complex data fetching, use Vue, React, or Svelte in their intended context.
Performance is critical and JavaScript-free is possible — for purely aesthetic animations (scroll-triggered fades, hover effects), CSS alone is faster and more reliable than JavaScript. Reserve Alpine for behaviours that genuinely require JavaScript.
For the vast majority of Jekyll sites — marketing pages, blogs, documentation, portfolios — Alpine provides everything you need with none of the overhead of a full framework. It is one of the most productive tools you can add to a static site build.
Browse Jekyll themes on JekyllHub to find themes that include Alpine.js for interactivity out of the box.
Combining Alpine.js with Jekyll collections
Alpine’s filtering and sorting capabilities combine naturally with Jekyll’s collections. The Jekyll build generates all the data as HTML; Alpine handles the interactive parts without a server.
For a Jekyll theme marketplace, a common requirement is a filterable grid with multiple simultaneous filters — category, price, and a search query all active at once. Here is how that works with Alpine:
<div x-data="{
query: '',
category: 'all',
price: 'all',
get filtered() {
return this.category === 'all' && this.price === 'all' && this.query === '';
}
}">
<!-- Filters row -->
<div class="filters">
<input x-model="query" type="search" placeholder="Search themes...">
<div class="filter-tabs">
<button @click="category = 'all'" :class="{'active': category === 'all'}">All</button>
<button @click="category = 'blog'" :class="{'active': category === 'blog'}">Blog</button>
<button @click="category = 'portfolio'" :class="{'active': category === 'portfolio'}">Portfolio</button>
</div>
<select x-model="price">
<option value="all">Any price</option>
<option value="free">Free</option>
<option value="paid">Paid</option>
</select>
</div>
<!-- Theme grid -->
<div class="theme-grid">
{% for theme in site.themes %}
<div class="theme-card"
x-show="
(category === 'all' || '{{ theme.category | downcase }}' === category) &&
(price === 'all' || ('{{ theme.price }}' === '0' ? 'free' : 'paid') === price) &&
('{{ theme.title | downcase }}'.includes(query.toLowerCase()) || query === '')
">
<h3>{{ theme.title }}</h3>
</div>
{% endfor %}
</div>
<!-- Empty state -->
<div x-show="document.querySelectorAll('.theme-card[style*=\'display: none\']').length === {{ site.themes.size }}"
class="empty-state">
<p>No themes match your filters. <button @click="query=''; category='all'; price='all'">Clear filters</button></p>
</div>
</div>
The key insight: Jekyll renders all 70 theme cards at build time as static HTML. Alpine’s x-show toggles their visibility client-side. The result is instant filtering with zero API calls and zero JavaScript framework overhead beyond Alpine’s 15kb.
Using Alpine with localStorage for persistent state
User preferences — selected category, sort order, theme toggle — should persist across page loads. Alpine’s $persist magic property (available via a separate plugin) makes this easy:
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
Then use $persist in your component:
<div x-data="{
category: $persist('all'),
sortBy: $persist('stars'),
view: $persist('grid')
}">
<!-- The selected category, sort order, and view mode are remembered across visits -->
</div>
Without the persist plugin, use localStorage directly in Alpine’s init hook:
<div x-data="{
view: localStorage.getItem('themeView') || 'grid',
setView(v) {
this.view = v;
localStorage.setItem('themeView', v);
}
}">
<button @click="setView('grid')">Grid</button>
<button @click="setView('list')">List</button>
</div>
Accessibility with Alpine.js
Alpine makes it easy to add ARIA attributes dynamically — ensuring your interactive components work for keyboard and screen reader users, not just mouse users.
Accordion with proper ARIA:
<div x-data="{ open: false }">
<h3>
<button
@click="open = !open"
:aria-expanded="open.toString()"
aria-controls="panel-1"
class="accordion-trigger"
>
Question text here
<svg :class="open ? 'rotate-180' : ''" class="accordion-icon" aria-hidden="true">...</svg>
</button>
</h3>
<div
id="panel-1"
role="region"
x-show="open"
x-transition
:aria-hidden="(!open).toString()"
>
Answer text here.
</div>
</div>
Modal with focus trap:
<div x-data="{ open: false }">
<button @click="open = true">Open modal</button>
<div
x-show="open"
@keydown.escape.window="open = false"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
x-trap.noscroll="open" <!-- Requires @alpinejs/focus plugin -->
>
<h2 id="modal-title">Modal Title</h2>
<button @click="open = false" aria-label="Close modal">×</button>
<!-- Content -->
</div>
</div>
The x-trap directive (from the @alpinejs/focus plugin) keeps keyboard focus inside the modal while it is open — the correct behaviour required by WCAG for modal dialogs. Without it, keyboard users can Tab out of the modal into the background content.
These details — aria-expanded, aria-modal, focus trapping — are what separate a component that works from one that is genuinely accessible. Alpine makes them straightforward to implement.
Performance impact of Alpine.js
At 15kb minified and gzipped, Alpine is one of the lightest JavaScript frameworks available. For comparison: React is roughly 45kb (core only), Vue is 34kb, and Svelte compiles to zero framework runtime but grows with application code. Alpine’s impact on Lighthouse scores is minimal — particularly if loaded with defer, it does not block rendering.
One performance consideration: elements controlled by x-show="false" are still rendered in the DOM; they are simply set to display: none. For very long lists (hundreds of items), this can slow initial page rendering. In those cases, use x-if instead of x-show — x-if does not render the element at all when the condition is false, keeping the initial DOM lean.
For server-rendered or statically generated content (which is all Jekyll content), the standard advice about client-side rendering performance does not apply. Your HTML is already fully rendered before Alpine runs. Alpine’s job is purely to add interactivity to existing markup, not to render it — which is exactly the performance-friendly use case it was designed for.
Alpine is also composable with other micro-libraries. You might use Swiper for a carousel (more capable than Alpine’s native features), PhotoSwipe for a lightbox, and Alpine for everything else. Each library handles its specific domain; Alpine handles the general interactive logic between them.
The developer experience is excellent for Jekyll specifically because you work within a file you already understand — the Liquid template. You do not switch contexts between a template file and a JavaScript component file. The behaviour and the markup are co-located, which makes reading and debugging straightforward. This co-location is Alpine’s strongest selling point for server-rendered and statically generated sites.
Alpine.js consistently earns high satisfaction ratings among Jekyll developers because it respects the static site paradigm: your HTML is generated at build time, complete and semantic, and Alpine layers behaviour on top without taking over rendering. The result is a site that is fast, accessible, and fully functional even before JavaScript runs — qualities that matter for SEO, performance, and users on slow connections. For most Jekyll sites, Alpine covers every interactivity need without the cognitive and bundle overhead of a full-stack JavaScript framework.
Adding Alpine to your Jekyll theme
Including Alpine in a Jekyll theme is a single <script> tag added to _layouts/default.html, ideally in the <head> with the defer attribute, or just before the closing </body> tag. Because Jekyll serves fully-rendered HTML, Alpine does not need to be loaded before the page renders — it only needs to run before the user interacts with an element. Loading it with defer achieves this while preventing any render-blocking behaviour.
If your theme uses multiple Alpine components across many pages, consider loading Alpine on every page rather than conditionally per-page. The 15kb bundle is small enough that the overhead is negligible compared to the complexity of conditionally injecting it. For sites that only use Alpine on one or two pages, the conditional approach is fine — use a page.alpine front matter flag and an {% if page.alpine %} guard around the script tag in your layout.
Structuring Alpine components as reusable Alpine.data() registrations (in a separate assets/js/components.js file) keeps your HTML templates clean and makes the JavaScript logic easy to test. Individual template files focus on markup; the behaviour lives in the component registration. This separation mirrors how Jekyll itself separates content (Markdown) from structure (layouts and includes), making the overall codebase consistent and predictable for anyone who works on it after you.