
Neiki’s Gallery is a vanilla JavaScript image gallery and lightbox library that displays responsive image collections, videos, embeds, and full-screen media overlays.
It supports masonry, grid, mosaic, and horizontal filmstrip layouts for photo-heavy websites, portfolios, documentation pages, and media archives.
Features:
- Four gallery layout modes: masonry, grid, mosaic, and filmstrip (horizontal scroll).
- Glassmorphism lightbox with a backdrop-blur overlay and a floating toolbar.
- Slideshow with an animated progress bar, play/pause, and a Ken Burns zoom-and-pan option per slide.
- Before/after image comparison via a dedicated static utility method.
- Tag filtering with an auto-generated pill filter bar from
data-tagsattributes. - Batch multi-select via Shift+click, with checkmark overlays on selected items.
- Contextual zoom to the exact cursor point, with pan support after zooming in.
- Thumbnail strip in the lightbox with a scale hover effect.
- Lazy loading with
IntersectionObserverand shimmer placeholders. - Blurhash placeholder support via a
data-blurhashattribute decoded on load. - EXIF data overlay in the lightbox showing camera model, aperture, ISO, and shutter speed.
- Five-color palette strip extracted from the current image via k-means quantization.
- Backdrop tint that adapts the overlay color to the dominant color of the current image.
- FLIP morph transition for a smooth thumbnail-to-lightbox open animation.
- Story mode for an Instagram-style vertical fullscreen viewer with progress bars.
- Picture-in-picture mode to minimize the lightbox to a resizable corner window.
- Virtual scrolling via
content-visibilityfor galleries with 50 or more items. - Drag-and-drop reorder of grid items via the HTML5 drag API.
- Video support with auto-detection of MP4, WebM, YouTube, and Vimeo URLs.
- Favorites system with heart button and localStorage persistence.
- Slide-out info panel with metadata and EXIF data.
- In-lightbox image editor with rotate, flip, and PNG export via canvas.
- Freehand annotation layer with color picker, brush size, undo, clear, and export.
- Right-click context menu on gallery items.
- Infinite scroll with a
loadMorecallback and runtimeappend/removeAPI. - Full keyboard navigation including arrows, Escape, Home/End, F for fullscreen, and Space for slideshow.
- Touch and swipe support for left/right navigation on mobile.
- URL hash deep linking for direct navigation to a specific image.
- Album groups that link multiple galleries and traverse navigation across all of them.
- Dark, light, and system (OS preference) theme modes via CSS custom properties.
- Accessibility with ARIA attributes, focus management, and
prefers-reduced-motionsupport. - Rich event system covering open, close, change, filter, select, slideshow, favorites, editor export, and more.
- Plugin system with
init,open,change,close, anddestroylifecycle hooks.
Use Cases:
- Photography portfolio sites where visitors need a full-featured lightbox with EXIF data and keyboard navigation.
- E-commerce product pages where before/after comparison or image annotation adds clarity to product details.
- CMS-driven media libraries where infinite scroll and runtime append keep large image sets performant.
- Multi-gallery editorial pages where album groups tie related galleries into a single navigable experience.
How to use it:
1. Download and load Neiki’s Gallery’s JavaScript & stylesheet in the document.
<link rel="stylesheet" href="/dist/neiki-gallery.css"> <script src="/dist/neiki-gallery/neiki-gallery.min.js"></script>
2. Add the data-neiki-gallery attribute to the gallery container. Each link points to the full-size media file, and each nested image acts as the thumbnail.
Galleries with
data-neiki-galleryinitialize automatically after the page loads.
<div data-neiki-gallery data-layout="masonry" data-theme="dark">
<!-- Opens the full-size city image in the lightbox -->
<a href="media/city-rooftop-full.jpg" data-caption="Rooftop view after sunset">
<img src="media/city-rooftop-thumb.jpg" alt="City rooftop at dusk">
</a>
<!-- Opens the full-size forest image in the same gallery -->
<a href="media/forest-trail-full.jpg" data-caption="Morning trail through pine trees">
<img src="media/forest-trail-thumb.jpg" alt="Pine forest trail">
</a>
<!-- Opens the full-size studio image in the same gallery -->
<a href="media/studio-desk-full.jpg" data-caption="Workspace setup for product shots">
<img src="media/studio-desk-thumb.jpg" alt="Studio desk with camera gear">
</a>
</div>3. You can also initialize the gallery manually. All available options:
layout(string): Gallery layout mode. Accepts'masonry','grid','mosaic', or'filmstrip'. Default:'masonry'.loop(boolean): Loops navigation from the last image back to the first. Default:false.thumbnails(boolean): Shows the scrollable thumbnail strip inside the lightbox. Default:true.zoom(boolean): Toggles zoom on image click inside the lightbox. Default:true.contextualZoom(boolean): Zooms toward the cursor point rather than the image center. Default:false.fullscreen(boolean): Shows the native fullscreen button in the toolbar. Default:true.transition(string): Lightbox transition style. Accepts'fade'or'slide'. Default:'fade'.theme(string): Color theme. Accepts'dark','light', or'system'(follows OS preference). Default:'system'.hashNavigation(boolean): Updates the URL hash for deep linking to a specific image. Default:true.counter(boolean): Shows the image counter badge. Default:true.counterFormat(string): Counter display template. Supports{current},{total}, and{percent}tokens. Default:'{current} / {total}'.captions(boolean): Shows thedata-captiontext in the lightbox. Default:true.preload(number): Number of adjacent images to preload. Default:1.lazyLoad(boolean): Lazy loads grid thumbnails viaIntersectionObserver. Default:true.stagger(boolean): Plays a staggered fade-in animation when the grid loads. Default:true.slideshow(boolean | object): Enables slideshow autoplay. Passtrueor a config object withinterval,pauseOnHover,kenburns, anddirection. Default:false.share(boolean): Shows the share/download button in the lightbox toolbar. Default:true.filter(boolean): Renders a tag filter bar above the gallery fromdata-tagsvalues. Default:false.batchSelect(boolean): Enables Shift+click multi-select with checkmark overlays. Default:false.focusPoint(boolean): Readsdata-focuson thumbnails to setobject-positionfor smart cropping. Default:true.blurhash(boolean): Decodesdata-blurhashattributes into blurred placeholders. Default:true.exif(boolean): Shows an EXIF data overlay in the lightbox for JPEG images. Default:false.storyMode(boolean): Adds an Instagram-style vertical fullscreen viewer button. Default:false.pip(boolean): Adds a picture-in-picture button to minimize the lightbox. Default:false.virtualScroll(boolean): Appliescontent-visibilityvirtualization for 50+ item galleries. Default:false.dragReorder(boolean): Enables HTML5 drag-to-reorder on grid items. Default:false.backdropTint(boolean): Adapts the overlay color to the dominant color of the current image. Default:false.morphTransition(boolean): Plays a FLIP animation from the grid thumbnail to the lightbox. Default:false.colorPalette(boolean): Shows a five-color extracted palette strip in the lightbox. Default:false.aspectSkeleton(boolean): Usesdata-widthanddata-heightto size loading skeletons. Default:true.video(boolean): Auto-detects MP4, WebM, YouTube, and Vimeo URLs for in-lightbox playback. Default:true.plugins(array): Plugin names or config objects to instantiate on this gallery. Default:null.group(string): Album group name. Navigation traverses all galleries sharing the same group. Also settable viadata-group. Default:''.favorites(boolean): Shows a heart button and persists favorites to localStorage. Default:false.favoritesKey(string): Custom suffix appended to the localStorage key for favorites. Default:''.infoPanel(boolean): Shows a slide-out sidebar with metadata and EXIF data (I key). Default:false.contextMenu(boolean): Adds a right-click context menu to each gallery item. Default:false.shortcutsHelp(boolean): Shows a keyboard shortcuts overlay when the user presses?. Default:true.infiniteScroll(boolean): Auto-loads more items on scroll when aloadMorefunction is set. Default:false.loadMore(function): Callback that receives the current item count and returns an array or Promise resolving to an array of new items. Default:null.editor(boolean): Adds a rotate/flip/export toolbar to the lightbox. Default:false.annotate(boolean): Adds a freehand drawing layer with color picker and export. Default:false.
// Creates a gallery instance from the selected container
const gallery = new NeikiGallery('#project-gallery', {
// Sets the gallery layout
layout: 'mosaic',
// Moves from the last item back to the first item
loop: true,
// Displays the thumbnail rail inside the lightbox
thumbnails: true,
// Enables click-to-zoom inside the lightbox
zoom: true,
// Uses the click position as the zoom origin
contextualZoom: true,
// Shows the browser fullscreen control
fullscreen: true,
// Uses slide transitions between media items
transition: 'slide',
// Follows the operating system theme
theme: 'system',
// Updates the URL hash for direct image links
hashNavigation: true,
// Shows the item counter
counter: true,
// Formats the counter text
counterFormat: '{current} of {total}',
// Animates grid items as they enter the page
stagger: true,
// Enables autoplay with a 5 second interval
slideshow: {
interval: 5000,
pauseOnHover: true,
kenburns: true,
direction: 'forward'
},
// Shows the share control
share: true,
// Builds a tag filter bar from item metadata
filter: true,
// Enables Shift-click multi-select
batchSelect: true,
// Uses smart cropping coordinates from each image
focusPoint: true,
// Uses blur previews before images load
blurhash: true,
// Shows camera metadata for supported JPEG files
exif: true,
// Adds story viewer mode
storyMode: true,
// Adds picture-in-picture mode
pip: true,
// Optimizes very large galleries
virtualScroll: true,
// Lets users reorder gallery items with drag and drop
dragReorder: true,
// Tints the overlay from the active image color
backdropTint: true,
// Animates the thumbnail into the lightbox view
morphTransition: true,
// Shows a 5-color palette strip for the active image
colorPalette: true,
// Reserves layout space from image dimensions
aspectSkeleton: true,
// Detects image, video, YouTube, and Vimeo media
video: true,
// Loads plugin instances
plugins: ['watermark'],
// Links galleries inside the same album group
group: 'case-study-media',
// Shows the favorite heart control
favorites: true,
// Stores favorites under a custom key suffix
favoritesKey: 'case-study',
// Shows the metadata sidebar
infoPanel: true,
// Adds a right-click menu
contextMenu: true,
// Shows the shortcut overlay from the question mark key
shortcutsHelp: true,
// Loads more items near the end of the gallery
infiniteScroll: true,
// Returns new items for infinite scroll
loadMore(currentLength) {
return [
{
src: `media/generated-${currentLength + 1}.jpg`,
thumb: `media/generated-${currentLength + 1}-thumb.jpg`,
caption: `Additional gallery item ${currentLength + 1}`
}
];
},
// Adds rotate and flip tools
editor: true,
// Adds the freehand drawing layer
annotate: true
});4. Use the following HTML Data attributes for declarative setup:
<div data-neiki-gallery
data-layout="mosaic"
data-theme="light"
data-transition="slide"
data-loop="true"
data-stagger="true"
data-slideshow="true"
data-slideshow-interval="4500"
data-slideshow-pause-on-hover="true"
data-slideshow-kenburns="false"
data-counter-format="{current} / {total}"
data-share="true"
data-filter="true"
data-batch-select="true"
data-contextual-zoom="true"
data-story-mode="true"
data-pip="true"
data-exif="true"
data-backdrop-tint="true"
data-morph-transition="true"
data-color-palette="true"
data-drag-reorder="true"
data-virtual-scroll="true"
data-aspect-skeleton="true"
data-group="editorial-shoot"
data-favorites="true"
data-info-panel="true"
data-context-menu="true"
data-shortcuts-help="true"
data-editor="true"
data-annotate="true"
data-video="true">
<!-- Adds tags, dimensions, media type, and focus coordinates -->
<a href="media/editorial-hero.jpg"
data-tags="portrait,studio"
data-size="large"
data-width="1600"
data-height="1100"
data-type="image">
<img src="media/editorial-hero-thumb.jpg"
alt="Editorial portrait with studio lighting"
data-focus="0.45 0.35"
data-blurhash="LEHV6nWB2y...">
</a>
<!-- Opens a local video file in the lightbox -->
<a href="media/product-loop.mp4"
data-tags="video,product"
data-poster="media/product-loop-poster.jpg"
data-type="video">
<img src="media/product-loop-thumb.jpg" alt="Product motion preview">
</a>
</div>5. API methods:
// Open the lightbox at a specific index (zero-based)
photos.open(2);
// Close the lightbox
photos.close();
// Advance to the next image (group-aware across album groups)
photos.next();
// Go back to the previous image (group-aware)
photos.prev();
// Start slideshow autoplay
photos.startSlideshow();
// Stop slideshow and emit stop event
photos.stopSlideshow();
// Pause slideshow — does not emit the stop event
photos.pauseSlideshow();
// Toggle slideshow on/off
photos.toggleSlideshow();
// Filter gallery to a tag; pass null to show all items
photos.filter('landscape');
// Get the array of batch-selected items
const selected = photos.getSelected();
// Clear all batch selections
photos.clearSelection();
// Get current item order as an index array (after drag reorder)
const order = photos.getOrder();
// Toggle favorite for the current lightbox image
photos.toggleFavorite();
// Check if a specific index is favorited
const starred = photos.isFavorite(0);
// Get all favorited image src URLs
const favorites = photos.getFavorites();
// Clear all favorites from localStorage
photos.clearFavorites();
// Toggle the info sidebar — pass true/false to force a state
photos.toggleInfoPanel(true);
// Toggle the keyboard shortcuts overlay
photos.toggleShortcutsHelp();
// Open the in-lightbox image editor (rotate/flip/export)
photos.openEditor();
photos.closeEditor();
// Retrieve the last exported edited image as a Blob
const editedBlob = photos.getEditedBlob();
// Open the freehand annotation layer
photos.openAnnotate();
photos.closeAnnotate();
// Retrieve the annotated image as a Blob
const annotatedBlob = photos.getAnnotatedBlob();
// Print the current (or a specified index) image
photos.print(1);
// Append new items to the gallery at runtime
photos.append([
{ src: 'photos/new-full.jpg', thumb: 'photos/new-thumb.jpg', caption: 'New arrival' }
]);
// Remove an item by index
photos.remove(3);
// Access a plugin instance by name
const watermark = photos.plugin('watermark');
// Register a one-time event listener (auto-removed after first fire)
photos.once('open', (index) => {
console.log('First open at index', index);
});
// Register a persistent event listener
photos.on('change', (index) => {
console.log('Now showing', index);
});
// Remove a specific listener for an event
photos.off('change', myHandler);
// Remove all listeners for an event
photos.off('change');
// Destroy the gallery — removes all listeners and DOM additions
photos.destroy();6. Static utilities:
// Before/after comparison slider on a container element
const compare = NeikiGallery.compare('#diff-container', {
before: 'photos/edit-before.jpg',
after: 'photos/edit-after.jpg',
labelBefore: 'Original',
labelAfter: 'Retouched',
startPosition: 50 // Handle starts at 50% from the left
});
// Move the comparison handle programmatically to 30%
compare.setPosition(30);
// Destroy the comparison slider
compare.destroy();
// Extract a 5-color palette from an image (k-means quantization)
NeikiGallery.extractPalette('photos/hero.jpg', 5, (colors) => {
// colors = [{r, g, b, hex}, ...]
console.log('Palette:', colors);
});
// Extract the single dominant color from an image
NeikiGallery.extractDominantColor('photos/hero.jpg', (color) => {
console.log('Dominant:', color.hex);
});
// Parse EXIF tags from a JPEG binary
NeikiGallery.parseExif('photos/raw-shot.jpg', (tags) => {
// tags = { make, model, iso, fNumber, exposure, focalLength }
console.log('ISO:', tags.iso);
});
// Decode a blurhash string into pixel data
const pixels = NeikiGallery.decodeBlurhash('LEHV6nWB2yk8pyo0adR*.7kCMdnj', 32, 32);
// Detect the media type of a URL string
NeikiGallery.detectMediaType('https://www.youtube.com/watch?v=xyz'); // 'youtube'
NeikiGallery.detectMediaType('clips/reel.mp4'); // 'video'
NeikiGallery.detectMediaType('photos/portrait.jpg'); // 'image'
// Read the current library version
console.log(NeikiGallery.version); // '3.0.0'7. Events:
// Fires when the lightbox opens; receives the image index
photos.on('open', (index) => {
console.log('Opened at index', index);
});
// Fires when the lightbox closes
photos.on('close', () => {
console.log('Lightbox closed');
});
// Fires on every slide change; receives the new index
photos.on('change', (index) => {
console.log('Slide changed to', index);
});
// Fires when the tag filter changes; receives the active tag string
photos.on('filter', (tag) => {
console.log('Active filter:', tag);
});
// Fires when the batch selection changes; receives an array of selected indices
photos.on('select', (indices) => {
console.log('Selected indices:', indices);
});
// Fires when slideshow autoplay starts
photos.on('slideshowStart', () => {
console.log('Slideshow running');
});
// Fires when slideshow autoplay stops
photos.on('slideshowStop', () => {
console.log('Slideshow stopped');
});
// Fires when drag reorder completes; receives the new order array
photos.on('reorder', (order) => {
console.log('New order:', order);
});
// Fires when an image is favorited
photos.on('favorite', ({ index, src }) => {
console.log('Favorited:', src, 'at index', index);
});
// Fires when a favorite is removed
photos.on('unfavorite', ({ index, src }) => {
console.log('Removed favorite:', src);
});
// Fires when the info panel opens
photos.on('infoOpen', () => {
console.log('Info panel visible');
});
// Fires when the info panel closes
photos.on('infoClose', () => {
console.log('Info panel hidden');
});
// Fires when the user prints an image
photos.on('print', ({ index, src }) => {
console.log('Printed index', index, src);
});
// Fires when the editor exports an image; receives Blob and object URL
photos.on('editorExport', ({ blob, url }) => {
console.log('Edited image available at', url);
});
// Fires when the annotation layer exports; receives Blob and object URL
photos.on('annotateExport', ({ blob, url }) => {
console.log('Annotated image available at', url);
});
// Fires when items are appended at runtime; receives the new items array
photos.on('append', (items) => {
console.log(items.length, 'items added');
});
// Fires when an item is removed at runtime; receives the removed index
photos.on('remove', ({ index }) => {
console.log('Removed item at', index);
});
// Fires when picture-in-picture mode activates
photos.on('pipEnter', () => {
console.log('Gallery minimized to PiP window');
});
// Fires when story mode activates
photos.on('storyEnter', () => {
console.log('Story mode opened');
});8. Register a plugin globally before creating any gallery instance that uses it:
// Register a plugin factory — receives the gallery instance and options
NeikiGallery.registerPlugin('analytics', (gallery, opts) => ({
// Runs once after gallery initialization
init() {
console.log('[analytics] ready — sampleRate:', opts.sampleRate);
},
// Runs each time the lightbox opens
open(data) {
track('lightbox_open', { index: data.index });
},
// Runs on every slide change
change(data) {
track('slide_view', { src: data.item.src, index: data.index });
},
// Runs when the lightbox closes
close() {
track('lightbox_close');
},
// Cleanup — runs on gallery.destroy()
destroy() {
console.log('[analytics] teardown');
}
}));
// Pass the plugin name (and any options) to the gallery
const monitored = new NeikiGallery('#featured', {
plugins: [{ name: 'analytics', sampleRate: 1.0 }]
});
// Access the running plugin instance
const analyticsPlugin = monitored.plugin('analytics');
// List all registered plugin names
console.log(NeikiGallery.getRegisteredPlugins()); // ['analytics']
// Unregister a plugin globally
NeikiGallery.unregisterPlugin('analytics');9. Neiki’s Gallery exposes its full visual system as CSS custom properties. Override any variable at the :root level or scope it to a specific gallery container:
:root {
--neiki-columns: 3; /* Grid column count */
--neiki-gap: 12px; /* Gap between tiles */
--neiki-border-radius: 10px; /* Tile corner radius */
--neiki-accent: #f59e0b; /* Accent color — buttons, spinner, pills */
--neiki-overlay-bg: rgba(5, 5, 10, 0.88); /* Lightbox backdrop color */
--neiki-overlay-backdrop: blur(20px) saturate(1.3); /* Backdrop filter */
--neiki-btn-bg: rgba(255, 255, 255, 0.12); /* Toolbar button background */
--neiki-btn-color: #fff; /* Toolbar icon color */
--neiki-caption-color: #f0f0f0; /* Caption text color */
--neiki-spinner-color: var(--neiki-accent); /* Loading spinner color */
--neiki-thumb-size: 56px; /* Thumbnail strip height */
--neiki-zoom-scale: 2.5; /* Zoom level multiplier */
--neiki-stagger-delay: 0.05s; /* Per-item entrance delay */
--neiki-hover-lift: -6px; /* Tile lift on hover */
--neiki-hover-shadow: 0 16px 40px rgba(0, 0, 0, 0.3); /* Tile hover shadow */
--neiki-transition-duration: 0.25s; /* Global transition speed */
}
Switch themes at runtime by setting the data-theme attribute directly on the gallery container:
// Switch a running gallery from dark to light at runtime
document.querySelector('#travel-gallery').dataset.theme = 'light';Alternatives:
- PhotoSwipe: A widely used vanilla JS lightbox with a focus on touch gestures and mobile UX.
- GLightbox: A lightweight JS lightbox with inline, video, and iframe support.
- Fancybox: A feature-rich lightbox library with a commercial license for paid projects.
FAQs:
Q: Can I run multiple independent galleries on the same page?
A: Yes. Each call to new NeikiGallery(selector, options) produces an independent instance with its own state. Use different selectors or IDs for each gallery container.
Q: The lightbox opens but the full-size image is not loading. What should I check?
A: Verify that each <a> tag’s href attribute points to the correct full-size image path. The library reads href for the lightbox source, not the src of the nested <img>.
Q: How do I link two separate galleries so navigation continues across both?
A: Set the same group option on both gallery instances, or add a matching data-group attribute to both containers. Navigation with next() and prev() will then traverse items across all galleries in the group.
Q: Does the slideshow work on mobile without touch conflicts?
A: Yes. The slideshow coexists with swipe navigation. Swiping left or right advances the slide and resets the autoplay interval.
Q: How do I lazy-load more images from a server as the user scrolls?
A: Set infiniteScroll: true and pass a loadMore function that receives the current item count. Return an array of item objects or a Promise that resolves to one. The library calls loadMore automatically when the user reaches the bottom of the grid.
Changelog:
v3.2.0 (07/11/2026)
- zoomed images can now be dragged beyond the visible stage bounds. On mouse or touch release, the image smoothly springs back to the nearest valid position, making image inspection feel more natural while preserving full edge coverage.
v3.1.0 (06/26/2026)
- Bugfixes.
- Cursor on a zoomed image is now grab / grabbing instead of zoom-out to communicate that dragging is possible.







