JavaScript 87 views

Image Modal with Lazy Load and Lightbox Effect

Image Modal with Lazy Load and Lightbox Effect

By TWC Team • Feb 07, 2026

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) => {
Back to Snippets