with Full Keyboard Support
A lightbox gallery without jQuery and without external libraries: Alpine.js manages the state, arrow keys navigate, escape closes it, touch swipe works on mobile, and lazy loading keeps the initial load lean.
Table of Contents
- 1. Why Alpine.js for a Gallery Instead of a Library?
- 2. Data Structure: Images, State, and Navigation
- 3. Thumbnail Grid with Tailwind CSS and x-for
- 4. Lightbox Overlay: Display, Transition, and Closing
- 5. Keyboard Navigation: Arrow Keys, Escape, and Home/End
- 6. Touch Swipe for Mobile Without External Libraries
- 7. Lazy Loading with IntersectionObserver
- 8. Image Preloading for Smooth Navigation
- 9. Gallery Approaches Compared
- 10. Summary
- 11. FAQ
1. Why Alpine.js for a Gallery Instead of a Library?
Gallery and lightbox libraries like Fancybox, GLightbox, or Swiper.js are powerful, and often overkill because of it. They typically ship several kilobytes of JavaScript and CSS that go far beyond what a simple product gallery or portfolio actually needs. Worse still, many of these libraries require jQuery or bring their own complex event systems that clash with Alpine.js and Tailwind CSS. The result is conflicts, duplicate JavaScript, and bloated bundles.
Alpine.js can build a fully featured lightbox gallery, including keyboard navigation, touch support, lazy loading, and ARIA accessibility, in significantly less code than an external library alone weighs. The decisive advantage: the code is yours. It fits your existing styling system, is easy to extend, and creates no version conflicts. In the Hyvä Magento context this matters even more, since Hyvä is built around a jQuery-free frontend from the ground up.
The implementation has four layers. The data structure manages the state (which image is open, which index is active). The thumbnail grid renders the preview images. The lightbox overlay shows the full-size image with navigation. The keyboard handler and the touch handler take care of the different input methods. Alpine.js connects all of these layers with a handful of directives: no separate framework code, no build configuration, no event bus.
2. Data Structure: Images, State, and Navigation
The Alpine component manages an array of image objects, an active index, and a lightbox open state. Each image object contains at minimum src (full-size URL), thumb (thumbnail URL), alt (accessible alt text), and optionally caption for a caption. Navigation is implemented via two methods, prev() and next(), which change the index cyclically. Cyclical navigation means the first image follows the last, and the last precedes the first, so navigation never hits a dead end.
One important design decision is whether the gallery tracks lazy-loading state separately. A practical pattern: a loaded set stores the indices of images that have already been loaded. When the lightbox opens, the current image plus the next and previous images are preloaded, which makes navigating feel instant with no loading delay. As the user keeps navigating, the next images are preloaded in the background each time.
3. Thumbnail Grid with Tailwind CSS and x-for
The thumbnail grid is rendered with x-for from the images array. Each thumbnail is a button with @click="open(index)" and carries the correct ARIA markup: aria-label with the image's alt text, and aria-haspopup="dialog" since it opens a dialog. The image itself gets loading="lazy" for native browser lazy loading of the thumbnails, which speeds up the initial load. The hover effects are implemented with Tailwind classes, no additional CSS required.
One visual detail with a big UX payoff: the image currently shown in the lightbox gets a ring effect in the grid. :class="{ 'ring-2 ring-teal-500': lightboxOpen && currentIndex === index }" marks the active thumbnail while the lightbox is open. This gives users navigating by keyboard a visual reference point in the grid. At the same time, alt="" on decorative thumbnails prevents screen readers from reading out redundant descriptions, since the meaningful alt attribute belongs on the full-size image in the lightbox overlay.
// Alpine.js Gallery + Lightbox component
document.addEventListener('alpine:init', () => {
Alpine.data('imageGallery', (images = []) => ({
images,
currentIndex: 0,
lightboxOpen: false,
touchStartX: 0,
touchEndX: 0,
preloadedIndices: new Set(),
open(index) {
this.currentIndex = index;
this.lightboxOpen = true;
this.preloadAdjacent(index);
// Prevent background scroll
document.body.style.overflow = 'hidden';
},
close() {
this.lightboxOpen = false;
document.body.style.overflow = '';
},
prev() {
this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length;
this.preloadAdjacent(this.currentIndex);
},
next() {
this.currentIndex = (this.currentIndex + 1) % this.images.length;
this.preloadAdjacent(this.currentIndex);
},
preloadAdjacent(index) {
const toPreload = [
index,
(index + 1) % this.images.length,
(index - 1 + this.images.length) % this.images.length
];
toPreload.forEach(i => {
if (!this.preloadedIndices.has(i)) {
const img = new Image();
img.src = this.images[i].src;
this.preloadedIndices.add(i);
}
});
},
get currentImage() {
return this.images[this.currentIndex] || null;
}
}));
});
4. Lightbox Overlay: Display, Transition, and Closing
The lightbox overlay is shown and hidden with x-show="lightboxOpen" and animated with x-transition. The overlay itself carries role="dialog", aria-modal="true", and an aria-label with the current image title, all reactive via :aria-label. @click.self="close()" on the overlay closes the lightbox when the user clicks outside the image. This prevents accidental closing when clicking the image itself.
The navigation buttons get explicit aria-label attributes (aria-label="Previous image", aria-label="Next image") since they contain only icons. An image counter (x-text="`${currentIndex + 1} of ${images.length}`") gives both sighted and screen reader users context about their position in the gallery. When the lightbox closes, focus must return to the thumbnail element that triggered it. This is achieved by storing a reference to document.activeElement before opening.
5. Keyboard Navigation: Arrow Keys, Escape, and Home/End
Alpine.js makes keyboard navigation declarative. On the lightbox container you set @keydown.arrow-right="next()", @keydown.arrow-left="prev()", and @keydown.escape="close()". For these handlers to work, the lightbox container must be focusable (tabindex="-1") and receive focus when it opens. Without focus on the container, the keydown events go nowhere, because there is no focused element inside the lightbox to receive them.
Additional keyboard shortcuts improve the UX: @keydown.home="currentIndex = 0" jumps to the first image, and @keydown.end="currentIndex = images.length - 1" jumps to the last. These shortcuts follow the ARIA conventions for list navigation. Important: all keyboard handlers should use .prevent for arrow keys to stop the page from scrolling while the lightbox is open. @keydown.arrow-right.prevent="next()" prevents the page from scrolling horizontally.
<!-- Lightbox Overlay with full keyboard support -->
<div
x-show="lightboxOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.90);"
role="dialog"
aria-modal="true"
:aria-label="currentImage ? `Bild ${currentIndex+1} von ${images.length}: ${currentImage.alt}` : 'Galerie'"
tabindex="-1"
x-ref="lightbox"
@click.self="close()"
@keydown.arrow-right.prevent="next()"
@keydown.arrow-left.prevent="prev()"
@keydown.escape="close()"
@keydown.home.prevent="currentIndex = 0"
@keydown.end.prevent="currentIndex = images.length - 1"
@touchstart="touchStartX = $event.changedTouches[0].screenX"
@touchend="touchEndX = $event.changedTouches[0].screenX; touchEndX - touchStartX > 50 ? prev() : touchStartX - touchEndX > 50 ? next() : null">
<!-- Navigation: Previous -->
<button @click="prev()" aria-label="Vorheriges Bild"
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/25 flex items-center justify-center text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button>
<!-- Full-size image -->
<template x-if="currentImage">
<img :src="currentImage.src" :alt="currentImage.alt"
class="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
style="max-height: 85vh; max-width: 90vw;">
</template>
<!-- Caption + Counter -->
<div class="absolute bottom-4 left-0 right-0 text-center text-white">
<p x-text="currentImage?.caption" class="text-sm mb-1 opacity-80"></p>
<p x-text="`${currentIndex + 1} / ${images.length}`" class="text-xs opacity-50"></p>
</div>
<!-- Navigation: Next -->
<button @click="next()" aria-label="Nächstes Bild"
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/25 flex items-center justify-center text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</button>
<!-- Close button -->
<button @click="close()" aria-label="Galerie schließen"
class="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/10 hover:bg-white/25 flex items-center justify-center text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
6. Touch Swipe for Mobile Without External Libraries
Touch swipe gestures are essential for mobile galleries. Implementing them without an external library is surprisingly simple: touchstart stores the X position of the initial touch point, and touchend compares it to the end position. If the difference exceeds a threshold (typically 50px), the gallery navigates. A positive difference (finger moving right) means the previous image, a negative difference (finger moving left) means the next image, matching the natural swipe direction.
One detail that makes all the difference: use changedTouches[0].screenX instead of touches[0].screenX in the touchend event. The touches array is empty during the touchend event, because no finger is touching the screen anymore. changedTouches contains the touch points that changed since the last event. This is one of the most common bugs in custom touch implementations. For pinch-to-zoom, the vertical distance between two fingers can be evaluated via touches[0] and touches[1], but that goes beyond a lightbox and is not necessary for simple galleries.
7. Lazy Loading with IntersectionObserver
Native browser lazy loading via loading="lazy" works great for thumbnails and needs no JavaScript at all. For full-size images in the lightbox the pattern is different: images should only load once they are actually shown, not on page load. This saves substantial bandwidth on large galleries. Alpine.js implements this either through a custom directive or directly inside the gallery component: the src attribute of the lightbox image is only set to the full-size URL once the lightbox opens.
For the thumbnail gallery itself, an IntersectionObserver inside an Alpine.js plugin can supplement native lazy loading support in browsers that don't fully support loading="lazy" yet. That's practically a non-issue today, but it remains instructive as a pattern. More important is preloading the neighboring images when the lightbox opens: the preloadAdjacent() pattern from section 2 loads the next and previous images in the background without blocking the current display.
8. Image Preloading for Smooth Navigation
Without preloading, the user briefly sees an empty image area every time they navigate in the lightbox while the next image loads. That kills the feeling of a smooth gallery. The preloading pattern solves this: as soon as an image opens, the next and previous images are loaded invisibly in advance. new Image(); img.src = url loads the image into the browser cache without displaying it in the DOM. By the time the user actually navigates, the image is already cached and appears instantly.
The preloadedIndices set prevents an image from being loaded more than once. Once loaded the first time, it sits in the browser cache and does not need to be requested from the server again. For very large galleries with many high-resolution images, preloading can be limited to the next two images forward and one backward to save bandwidth. The same pattern carries over directly to Alpine.js carousels and slideshows.
9. Gallery Approaches Compared
Choosing the right approach for a gallery depends on requirements, bundle budget, and maintainability.
| Approach | Bundle Size | Keyboard Support | Customizability |
|---|---|---|---|
| Alpine.js (custom impl.) | 0 kB (included in Alpine) | Full, WCAG | Full |
| Fancybox 5 | ~50 kB (JS + CSS) | Good | Limited (API) |
| GLightbox | ~20 kB (JS + CSS) | Basic | Medium (CSS) |
| Swiper.js | ~40 kB (JS + CSS) | Good | Medium (API) |
| Vanilla JS (no framework) | ~5 kB (custom code) | Manual | Full |
For Hyvä Themes and Magento 2, a custom Alpine.js implementation is almost always the right choice: no extra bundle, full control over styling with Tailwind, no conflicts with existing Alpine stores, and clean keyboard support that meets WCAG 2.1. External libraries make sense when you need very specific features that would make a custom build disproportionately expensive, such as 3D transitions, video embedding, or zoom functionality.
Mironsoft
Alpine.js gallery development, Hyvä Themes, and Magento 2 product galleries
A product gallery with Alpine.js for your Magento store?
We build fast, accessible product galleries and lightboxes for Hyvä themes: no external libraries, with full keyboard support and touch optimization.
Gallery Development
Lightbox, carousel, and thumbnail grid custom-built for Hyvä and Magento 2
Performance Optimization
Lazy loading, preloading, and WebP integration for optimal Core Web Vitals
Accessibility
WCAG-compliant keyboard navigation and screen reader support for product images
10. Summary
An Alpine.js image gallery with lightbox and keyboard support is entirely achievable without external libraries. The core building blocks: Alpine.data() for state management with index and open state, x-for for the thumbnail grid, x-show with x-transition for the lightbox overlay, declarative keyboard handlers with @keydown directives, touch swipe via touchstart and touchend events, and preloading of neighboring images via the Image() API.
The result is a gallery that needs zero kilobytes of additional JavaScript, is styled entirely with Tailwind CSS, offers WCAG-compliant keyboard navigation, and is usable on mobile devices with touch swipe. For Hyvä themes and Magento 2, this is the natural choice: no jQuery, no external dependencies, no conflict with the CSP system, and full control over styling.
Alpine.js Gallery with Lightbox: The Essentials at a Glance
State Management
images array, currentIndex, and lightboxOpen in Alpine.data(). prev() and next() navigate cyclically. preloadAdjacent() preloads neighboring images.
Keyboard Navigation
@keydown.arrow-right, arrow-left, escape, home, end on the focusable lightbox container (tabindex="-1"). .prevent stops the page from scrolling.
Touch Swipe
touchstart stores screenX. touchend compares it with changedTouches[0].screenX. A 50px threshold triggers prev() or next(). No external library needed.
Preloading
new Image(); img.src = url loads images into the browser cache without a DOM display. The preloadedIndices set prevents duplicate loading. Preload the current image plus 2 neighbors.