Image Modal with Lazy Load and Lightbox Effect
Image Modal with Lazy Load and Lightbox Effect
Code
/**
* Image Modal + Lazy Loading Lightbox (vanilla JS)
* Drop in once: lazy-load thumbnails with IntersectionObserver and open a reusable, accessible lightbox modal on click.
*/
(() => {
'use strict';
const SELECTOR = '[data-full]';
const LOADED_ATTR = 'data-loaded';
const supportsIO = 'IntersectionObserver' in window;
// Preload full image with robust error handling (async-friendly).
const preloadImage = async (src) =>
new Promise((resolve, reject) => {
if (!src) return reject(new Error('Missing image src.'));
const img = new Image();
img.decoding = 'async';
img.onload = () => resolve(src);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
// Lazily load thumbnails (or low-res placeholders) using data-src; falls back if IO unsupported.
const initLazyLoad = (root = document) => {
const candidates = [...root.querySelectorAll('img[data-src]:not([' + LOADED_ATTR + '])')];
if (!candidates.length) return;
const load = (img) => {
const { src } = img.dataset;
if (!src) return;
img.src = src;
img.loading = img.loading || 'lazy'; // hint; browser may ignore if already visible
img.setAttribute(LOADED_ATTR, 'true');
};
if (!supportsIO) return candidates.forEach(load);
const observer = new IntersectionObserver(
(entries, obs) => {
for (const { isIntersecting, target } of entries) {
if (!isIntersecting) continue;
load(target);
obs.unobserve(target);
}
},
{ root: null, rootMargin: '200px 0px', threshold: 0.01 } // performance: preload slightly before view
);
candidates.forEach((img) => observer.observe(img));
};
// Build a single reusable modal instance to avoid creating/destroying DOM per click.
const createLightbox = () => {
const modal = document.createElement('div');
modal.className = 'lb-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="lb-backdrop" data-lb-close tabindex="-1"></div>
<figure class="lb-content" role="document">
<button class="lb-close" type="button" aria-label="Close" data-lb-close>×</button>
<img class="lb-image" alt="" decoding="async" />
<figcaption class="lb-caption" aria-live="polite"></figcaption>
</figure>
`;
const style = document.createElement('style');
style.textContent = `
.lb-modal{position:fixed;inset:0;display:none;z-index:9999}
.lb-modal[aria-hidden="false"]{display:block}
.lb-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.82);backdrop-filter:saturate(120%) blur(2px)}
.lb-content{position:relative;max-width:min(1100px,92vw);max-height:88vh;margin:6vh auto 0;display:grid;gap:10px;justify-items:center}
.lb-image{max-width:100%;max-height:78vh;object-fit:contain;background:#111;border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.55);opacity:0;transform:scale(.985);transition:opacity .18s ease,transform .18s ease}
.lb-modal[data-ready="true"] .lb-image{opacity:1;transform:scale(1)}
.lb-caption{color:#eaeaea;font:500 14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;text-align:center;max-width:90%}
.lb-close{position:absolute;top:-10px;right:-10px;width:40px;height:40px;border:0;border-radius:999px;background:rgba(255,255,255,.12);color:#fff;font-size:26px;line-height:40px;cursor:pointer}
.lb-close:hover{background:rgba(255,255,255,.18)}
@media (prefers-reduced-motion:reduce){.lb-image{transition:none}}
`;
document.head.appendChild(style);
document.body.appendChild(modal);
const imgEl = modal.querySelector('.lb-image');
const captionEl = modal.querySelector('.lb-caption');
let lastActive = null;
let onKeyDown = null;
const close = () => {
modal.setAttribute('aria-hidden', 'true');
modal.removeAttribute('data-ready');
imgEl.removeAttribute('src');
imgEl.alt = '';
captionEl.textContent = '';
document.body.style.overflow = '';
if (onKeyDown) document.removeEventListener('keydown', onKeyDown);
if (lastActive && typeof lastActive.focus === 'function') lastActive.focus();
};
modal.addEventListener('click', (e) => {
if (e.target && e.target.closest('[data-lb-close]')) close();
});
const open = async ({ fullSrc, alt = '', caption = '' } = {}) => {
try {
lastActive = document.activeElement;
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden'; // prevent background scroll
imgEl.alt = alt;
captionEl.textContent = caption;
modal.removeAttribute('data-ready');
await preloadImage(fullSrc);
imgEl.src = fullSrc;
modal.setAttribute('data-ready', 'true');
// Keyboard UX: Esc closes; keep it simple and robust.
onKeyDown = (e) => {