/**
* JustifiedGallery
*
* A responsive justified image gallery with smooth transitions, modal preview,
* custom item styles, and dynamic hover effects.
*
* Features:
* - Responsive columns & max row height (with breakpoints)
* - Optional modal (zoomable, draggable, navigable)
* - Configurable hover effects (Lens, Ripple, Spotlight, etc.)
* - Custom per-item inline styles via `itemStyle`
* - Smooth animated layout transitions when column count changes (FLIP via overlay)
*
* Author: Aranéite (Antoine)
* Version: 2.4
*/
export default class JustifiedGallery {
/* =========================================================
* Constructor & public state
* ======================================================= */
/**
* @param {HTMLElement} container - The gallery container element.
* @param {Object} options - Configuration options.
*/
constructor(container, options = {}) {
if (!container) return;
this.container = container;
this.images = [];
this.imagesClone = [];
this.currentIndex = 0;
this.isOpenModal = false;
this.currentCols = null; // track current column count for breakpoint animation
this.options = {
margin: options.margin ?? 5,
columns: options.columns ?? 5,
breakpoints: options.breakpoints ?? { 1248: 3, 768: 2, 480: 1 },
hasModal: options.hasModal ?? false,
linkAttribute: options.linkAttribute || 'src',
justifyLastRow: options.justifyLastRow ?? false,
hoverEffect: options.hoverEffect || null,
itemStyle: options.itemStyle || {},
maxRowHeight: options.maxRowHeight ?? 350,
maxRowHeightBreakpoints: options.maxRowHeightBreakpoints ?? {
1248: 300,
768: 250,
480: 200,
},
};
// Initialisation
this.init();
// Handle responsive relayout on resize
this._onResize = () => {
if (!this.container) return;
const newCols = this._getColumns();
const shouldAnimateWrap =
this.currentCols !== null && newCols !== this.currentCols;
this.currentCols = newCols;
this.layoutGallery({ animateWrap: shouldAnimateWrap });
};
window.addEventListener('resize', this._onResize);
}
/* =========================================================
* Initialization
* ======================================================= */
/**
* Initialize gallery:
* - wrap images in anchors
* - wait for load
* - initial layout
* - modal / hover / custom styles
*/
async init() {
// Start hidden → fade-in after first layout
this.container.style.opacity = '0';
// Initial raw images
this.images = this._getImages();
this.imagesClone = this._getImagesClones();
// Wrap each image in a link () and attach hover class
this.images.forEach(img => this._wrapImage(img));
// Refresh internal image list (now wrapped by )
this.images = this._getImages();
// Wait for all images to be loaded (or failed)
const promises = this.images.map(img => this._waitForImageLoad(img));
await Promise.all(promises);
// First layout (no animation on initial render)
this.currentCols = this._getColumns();
this.layoutGallery({ animateWrap: false });
// Modal, hover effects, and custom item styles
this.setupModal();
this.initHoverEffects();
this.applyItemStyles();
// Smooth fade-in
requestAnimationFrame(() => {
this.container.style.transition = 'opacity 0.6s ease';
this.container.style.opacity = '1';
});
}
/**
* Apply custom styles defined in options.itemStyle
* to each gallery item (link wrapper).
*/
applyItemStyles() {
this.images.forEach(img => {
const wrapper = img.parentNode;
for (const key in this.options.itemStyle) {
wrapper.style[key] = this.options.itemStyle[key];
}
});
}
/**
* Initialize hover effects (spotlight, ripple, lens...)
* once DOM is ready and images are wrapped.
*/
initHoverEffects() {
const effect = this.options.hoverEffect;
// Spotlight effect (dynamic import)
if (effect === 'hover-spotlight') {
import('./effects/spotlight.js')
.then(({ enableSpotlightEffect }) => {
enableSpotlightEffect(this.container);
})
}
// Ripple effect: radial animation on mouse enter
if (effect === 'hover-ripple-animate') {
import('./effects/ripple-animate.js')
.then(({ enableRippleEffect }) => {
enableRippleEffect(this.container);
})
}
// Lens effect: zoomed circular region following the cursor
if (effect === 'hover-lens') {
import('./effects/lens.js')
.then(({ enableLensEffect }) => {
enableLensEffect(this.container);
})
}
}
/**
* Clean up listeners and DOM references.
*/
destroy() {
window.removeEventListener('resize', this._onResize);
if (this.modal && document.body.contains(this.modal)) {
this.modal.remove();
}
// Remove any listeners on images by cloning them
if (this.images && this.images.length) {
this.images.forEach(img => {
const clone = img.cloneNode(true);
img.replaceWith(clone);
});
}
this.container = null;
this.images = null;
this.modal = null;
this.modalImg = null;
this.closeBtn = null;
this.prevBtn = null;
this.nextBtn = null;
this.isOpenModal = false;
}
/* =========================================================
* Layout computation & application
* ======================================================= */
/**
* Compute layout data for all rows without mutating the DOM.
*
* @returns {Array<{ item: HTMLElement, img: HTMLImageElement,
* width: number, height: number,
* marginRight: number, marginBottom: number }>}
*/
_computeLayout() {
const containerWidth = this.container.clientWidth;
const margin = this.options.margin;
const cols = this._getColumns();
const maxRowHeight = this._getMaxRowHeight();
const justifyLastRow = this.options.justifyLastRow;
const layout = [];
let rowImages = [];
let totalRatio = 0;
this.images.forEach((img, index) => {
const ratio = img.naturalWidth / img.naturalHeight || 1;
const item = img.parentNode;
rowImages.push({ item, img, ratio });
totalRatio += ratio;
const isLastRow = index === this.images.length - 1;
if (rowImages.length === cols || isLastRow) {
let rowHeight;
if (isLastRow && rowImages.length < cols && !justifyLastRow) {
// Do not stretch last row: use average ratio and target less height
const avgRatio = totalRatio / rowImages.length;
rowHeight = Math.floor((containerWidth / cols) / avgRatio);
rowHeight = Math.min(rowHeight, maxRowHeight);
} else {
// Normal justified row: fill container width
rowHeight = Math.floor(
(containerWidth - margin * (rowImages.length - 1)) / totalRatio
);
rowHeight = Math.min(rowHeight, maxRowHeight);
// Stretch to perfectly fill the row horizontally
const totalWidth =
rowImages.reduce((sum, r) => sum + rowHeight * r.ratio, 0) +
margin * (rowImages.length - 1);
if (totalWidth > 0) {
const stretchFactor = containerWidth / totalWidth;
rowHeight *= stretchFactor;
}
}
rowImages.forEach((r, idx) => {
const width = rowHeight * r.ratio;
layout.push({
item: r.item,
img: r.img,
width,
height: rowHeight,
marginRight: idx < rowImages.length - 1 ? margin : 0,
marginBottom: margin,
});
});
rowImages = [];
totalRatio = 0;
}
});
// Keep in sync with actual columns used
this.currentCols = this._getColumns();
return layout;
}
/**
* Apply computed layout to DOM elements (without animation).
* @param {Array} layout - Layout entries from _computeLayout().
*/
_applyLayout(layout) {
layout.forEach(entry => {
const { item, img, width, height, marginRight, marginBottom } = entry;
// Anchor wrapper sized using computed layout
item.style.width = `${width}px`;
item.style.height = `${height}px`;
item.style.display = 'inline-block';
item.style.marginRight = `${marginRight}px`;
item.style.marginBottom = `${marginBottom}px`;
item.style.transition = 'none';
// Image fills its wrapper
img.style.width = '100%';
img.style.height = '100%';
img.style.cursor = 'pointer';
img.style.transition = 'none';
});
}
/**
* Main layout function.
* - If animateWrap = true, uses overlay + FLIP-like animation
* when the number of columns changes (breakpoint hit).
* - Otherwise only recomputes justification without wrap animation.
*
* @param {Object} options
* @param {boolean} options.animateWrap
*/
layoutGallery({ animateWrap = false } = {}) {
if (!this.container || !this.images || !this.images.length) return;
const layout = this._computeLayout();
// Simple re-justification when column count did not change
if (!animateWrap) {
this._applyLayout(layout);
return;
}
/* ---------------------------------------------
* Animated wrap change using fixed overlay + clones
* ------------------------------------------- */
// 1) Capture current positions BEFORE applying new layout
const prevRects = this._getWrapperRects();
const containerRect = this.container.getBoundingClientRect();
// 2) Create overlay (fixed) containing clones at old positions
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.left = `${containerRect.left}px`;
overlay.style.top = `${containerRect.top}px`;
overlay.style.width = `${containerRect.width}px`;
overlay.style.height = `${containerRect.height}px`;
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '9999';
const wrappers = this.images.map(img => img.parentNode);
const clones = [];
wrappers.forEach((wrapper, index) => {
const r = prevRects[index];
if (!r) return;
const clone = wrapper.cloneNode(true);
// Position & size BEFORE layout change
clone.style.position = 'absolute';
clone.style.left = `${r.left - containerRect.left}px`;
clone.style.top = `${r.top - containerRect.top}px`;
clone.style.width = `${r.width}px`;
clone.style.height = `${r.height}px`;
clone.style.margin = '0';
clone.style.boxSizing = 'border-box';
clone.style.transition = 'none';
clones.push(clone);
overlay.appendChild(clone);
});
document.body.appendChild(overlay);
// Hide real items during animation to avoid flicker/misalignment
wrappers.forEach(w => {
w.style.opacity = '0';
});
// 3) Apply NEW layout (on the real gallery, but hidden)
this._applyLayout(layout);
// 4) Measure final positions AFTER layout
const newRects = wrappers.map(w => w.getBoundingClientRect());
// 5) Animate clones from old to new rects
requestAnimationFrame(() => {
clones.forEach((clone, index) => {
const newRect = newRects[index];
const final = layout[index]; // final width/height from layout
if (!newRect || !final) return;
clone.style.transition = 'all 0.6s cubic-bezier(0.25, 1, 0.5, 1)';
// Final position
clone.style.left = `${newRect.left - containerRect.left}px`;
clone.style.top = `${newRect.top - containerRect.top }px`;
// Final size (should match real item => avoids jump)
clone.style.width = `${newRect.width }px`;
clone.style.height = `${newRect.height}px`;
});
});
// 6) Cleanup: remove overlay & reveal real gallery
setTimeout(() => {
overlay.remove();
wrappers.forEach(w => {
w.style.opacity = '1';
});
}, 650);
}
/* =========================================================
* Modal viewer
* ======================================================= */
/**
* Setup modal viewer if enabled.
*/
setupModal() {
if (!this.options.hasModal) return;
this.modal = document.createElement('div');
this.modal.classList.add('jg-modal');
this.modal.innerHTML = `
×
❮
❯
`;
document.body.appendChild(this.modal);
this.modalImg = this.modal.querySelector('.jg-modal-img');
this.closeBtn = this.modal.querySelector('.jg-close');
this.prevBtn = this.modal.querySelector('.jg-prev');
this.nextBtn = this.modal.querySelector('.jg-next');
// Open modal on image click
this.images.forEach((img, idx) => {
img.addEventListener('click', e => {
e.preventDefault();
this.openModal(idx);
});
});
// Basic modal controls
this.closeBtn.addEventListener('click', () => this.closeModal());
this.prevBtn.addEventListener('click', () => this.prevImage());
this.nextBtn.addEventListener('click', () => this.nextImage());
// Close when clicking outside image
this.modal.addEventListener('click', e => {
if (e.target === this.modal) this.closeModal();
});
// Keyboard navigation inside modal
document.addEventListener('keydown', e => {
if (this.modal?.style.display === 'flex') {
if (e.key === 'ArrowLeft') this.prevImage();
if (e.key === 'ArrowRight') this.nextImage();
if (e.key === 'Escape') this.closeModal();
}
});
this.initModalInteractions();
}
/**
* Initialize modal drag & zoom interactions.
*/
initModalInteractions() {
this.modalImg.currentScale = 1;
let isDragging = false;
let startX = 0;
let startY = 0;
let translateX = 0;
let translateY = 0;
// Mouse drag (panning)
this.modalImg.addEventListener('mousedown', e => {
if (e.button !== 0) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
this.modalImg.style.cursor = 'grabbing';
e.preventDefault();
});
this.modal.addEventListener('mousemove', e => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
startX = e.clientX;
startY = e.clientY;
translateX += dx;
translateY += dy;
this.modalImg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${this.modalImg.currentScale})`;
});
// Stop dragging on mouse up / leave
['mouseup', 'mouseleave'].forEach(event => {
this.modal.addEventListener(event, () => {
isDragging = false;
this.modalImg.style.cursor = 'grab';
});
});
// Scroll to zoom
this.modalImg.addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
this.modalImg.currentScale = Math.min(
Math.max(this.modalImg.currentScale + delta, 0.5),
3
);
this.modalImg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${this.modalImg.currentScale})`;
});
}
openModal(index) {
this.isOpenModal = true;
this.currentIndex = index;
this.modal.style.display = 'flex';
this.modalImg.src = this.images[this.currentIndex].src;
document.body.style.overflow = 'hidden';
}
closeModal() {
this.isOpenModal = false;
this.modal.style.display = 'none';
document.body.style.overflow = '';
this.modalImg.currentScale = 1;
this.modalImg.style.transform = 'translate(0px, 0px) scale(1)';
}
prevImage() {
this.currentIndex =
(this.currentIndex - 1 + this.images.length) % this.images.length;
this.modalImg.src = this.images[this.currentIndex].src;
}
nextImage() {
this.currentIndex =
(this.currentIndex + 1) % this.images.length;
this.modalImg.src = this.images[this.currentIndex].src;
}
/* =========================================================
* Private helpers
* ======================================================= */
/**
* @returns {HTMLImageElement[]} - All images inside the container.
* @private
*/
_getImages() {
return Array.from(this.container.querySelectorAll('img'));
}
/**
* @returns {HTMLElement[]} - All elements marked as `.jg-clone` (if any).
* @private
*/
_getImagesClones() {
return Array.from(this.container.querySelectorAll('.jg-clone'));
}
/**
* Wrap an image inside an anchor and attach hover class.
* @private
*/
_wrapImage(img) {
const href = img.getAttribute(this.options.linkAttribute) || img.src;
const a = document.createElement('a');
a.href = href;
a.style.display = 'inline-block';
// Preserve caption if present
a.dataset.caption = img.dataset.caption || '';
img.dataset.caption = '';
// Attach hover class if defined
this._setHover(a);
img.parentNode.insertBefore(a, img);
a.appendChild(img);
}
/**
* Attach a hover class on the wrapper if hoverEffect is configured.
* @private
*/
_setHover(el) {
if (this.options.hoverEffect) {
el.classList.add(this.options.hoverEffect);
}
}
/**
* Return the current columns count according to container width & breakpoints.
* @private
*/
_getColumns() {
const width = this.container.clientWidth;
let cols = this.options.columns;
const breakpoints = Object
.keys(this.options.breakpoints)
.map(Number)
.sort((a, b) => a - b);
for (const bp of breakpoints) {
if (width <= bp) {
cols = this.options.breakpoints[bp];
break;
}
}
return cols;
}
/**
* Return the max row height according to container width & maxRowHeightBreakpoints.
* @private
*/
_getMaxRowHeight() {
const width = this.container.clientWidth;
let maxHeight = this.options.maxRowHeight;
const breakpoints = this.options.maxRowHeightBreakpoints;
if (!breakpoints) return maxHeight;
const sorted = Object
.keys(breakpoints)
.map(Number)
.sort((a, b) => a - b);
for (const bp of sorted) {
if (width <= bp) {
maxHeight = breakpoints[bp];
break;
}
}
return maxHeight;
}
/**
* Wait until an image is loaded (or fails) before resolving.
* @private
*/
_waitForImageLoad(img) {
return new Promise(resolve => {
if (img.complete) {
resolve();
} else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
});
}
/**
* Get bounding rects of wrappers (anchors) before layout.
* @returns {DOMRect[]} rects ordered by image index
* @private
*/
_getWrapperRects() {
return this.images.map(img => img.parentNode.getBoundingClientRect());
}
}