Home
Contact Terms Privacy Catalog About

How to Add Lightbox to Blogger Images Without jQuery

Native JavaScript lightbox for Blogger using the data: layout tag and intersection observer lazy loading. No dependencies. No jQuery. Full keyboard accessibility and touch gesture support.

Mechanism

Blogger's <data:post.body> renders images without lightbox hooks. The solution intercepts DOM insertion, wraps images in structural containers, and attaches a lightweight modal engine. Two architectures exist: template-level injection via data:label conditioning, or post-render JavaScript traversal. Template-level is faster; post-render is more portable.

The lightbox engine uses a delegated click handler, srcset resolution for high-DPI displays, and history.pushState for back-button close behavior. Image loading is prioritized through fetchpriority="high" on the modal clone.

Template-Level Implementation

Inject the lightbox shell directly into Blogger's HTML template. This eliminates DOM traversal latency and avoids conflicts with dynamic views.

Step 1: Add Lightbox Shell to Template

Place before </body> in Theme → Edit HTML:

<!-- Lightbox Shell -->
<b:if cond='data:blog.pageType == "item"'>
  <div id='dox-lb' class='dox-lb' aria-hidden='true' role='dialog' aria-label='Image lightbox'>
    <div class='dox-lb-backdrop'</div>
    <figure class='dox-lb-stage'>
      <img class='dox-lb-img' src='' alt='' loading='eager' fetchpriority='high' />
      <figcaption class='dox-lb-caption'></figcaption>
    </figure>
    <button class='dox-lb-close' aria-label='Close lightbox'>&times;</button>
    <button class='dox-lb-nav dox-lb-prev' aria-label='Previous image' data-dir='-1'>&lsaquo;</button>
    <button class='dox-lb-nav dox-lb-next' aria-label='Next image' data-dir='1'>&rsaquo;</button>
    <div class='dox-lb-counter' aria-live='polite'></div>
  </div>
</b:if>

Step 2: Wrap Post Images on Render

Replace the default post body loop with a wrapped version. Locate <data:post.body/> and wrap with a processing container:

<div class='post-body' expr:id='"post-body-" + data:post.id'>
  <data:post.body/>
</div>

<!-- After post body: attach lightbox behavior -->
<b:if cond='data:blog.pageType == "item"'>
  <script>
    // Executed per-post; isolates multiple posts if needed
    window.doxLightboxQueue = window.doxLightboxQueue || [];
    window.doxLightboxQueue.push('post-body-<data:post.id />');
  </script>
</b:if>

Step 3: Core JavaScript Engine

Add before </body>. This module initializes all queued post bodies, wraps images, and manages modal state.

<script>
(function() {
  'use strict';

  const LB_ID = 'dox-lb';
  const WRAP_CLASS = 'dox-lb-wrap';
  const TRIGGER_CLASS = 'dox-lb-trigger';
  const ACTIVE_CLASS = 'open';
  const HIDDEN_CLASS = 'dox-lb-hidden';

  let lb, imgEl, captionEl, counterEl;
  let currentSet = [];
  let currentIndex = 0;

  function initLightbox(containerId) {
    const container = document.getElementById(containerId);
    if (!container) return;

    const images = container.querySelectorAll('img:not([data-lb-ignore])');
    const set = [];

    images.forEach((img, idx) => {
      // Skip images already in links or tiny tracking pixels
      if (img.closest('a') || img.naturalWidth < 50) return;

      const wrap = document.createElement('figure');
      wrap.className = WRAP_CLASS;
      wrap.dataset.lbIndex = idx;

      const trigger = document.createElement('button');
      trigger.className = TRIGGER_CLASS;
      trigger.type = 'button';
      trigger.setAttribute('aria-label', 'View image in lightbox');
      trigger.dataset.src = img.dataset.src || img.src;
      trigger.dataset.srcset = img.dataset.srcset || '';
      trigger.dataset.sizes = img.dataset.sizes || '';
      trigger.dataset.caption = img.alt || '';

      // Clone image for visual continuity
      const clone = img.cloneNode(false);
      clone.removeAttribute('loading'); // eager for visible
      trigger.appendChild(clone);

      wrap.appendChild(trigger);
      img.replaceWith(wrap);
      set.push({
        src: trigger.dataset.src,
        srcset: trigger.dataset.srcset,
        sizes: trigger.dataset.sizes,
        caption: trigger.dataset.caption
      });
    });

    if (!set.length) return;

    // Attach delegated handler once per container
    container.addEventListener('click', (e) => {
      const btn = e.target.closest('.' + TRIGGER_CLASS);
      if (!btn) return;
      e.preventDefault();
      const idx = parseInt(btn.closest('.' + WRAP_CLASS).dataset.lbIndex, 10);
      openLightbox(set, idx);
    });
  }

  function openLightbox(set, startIndex) {
    currentSet = set;
    currentIndex = startIndex;
    if (!lb) {
      lb = document.getElementById(LB_ID);
      imgEl = lb.querySelector('.dox-lb-img');
      captionEl = lb.querySelector('.dox-lb-caption');
      counterEl = lb.querySelector('.dox-lb-counter');
      bindControls();
    }

    document.documentElement.classList.add(HIDDEN_CLASS);
    lb.classList.add(ACTIVE_CLASS);
    lb.setAttribute('aria-hidden', 'false');
    history.pushState({doxLb: true}, '');
    loadImage(currentIndex);
  }

  function loadImage(idx) {
    const item = currentSet[idx];
    imgEl.removeAttribute('src');
    imgEl.removeAttribute('srcset');

    // Preload next/prev for instant navigation
    [idx - 1, idx + 1].forEach(preloadIndex => {
      if (currentSet[preloadIndex]) {
        const p = new Image();
        p.src = currentSet[preloadIndex].src;
      }
    });

    imgEl.src = item.src;
    if (item.srcset) {
      imgEl.srcset = item.srcset;
      imgEl.sizes = item.sizes || '100vw';
    }
    captionEl.textContent = item.caption || '';
    counterEl.textContent = currentSet.length > 1 ? `${idx + 1} / ${currentSet.length}` : '';
  }

  function closeLightbox() {
    lb.classList.remove(ACTIVE_CLASS);
    lb.setAttribute('aria-hidden', 'true');
    document.documentElement.classList.remove(HIDDEN_CLASS);
    imgEl.src = '';
    if (history.state && history.state.doxLb) {
      history.back();
    }
  }

  function move(dir) {
    currentIndex = (currentIndex + dir + currentSet.length) % currentSet.length;
    loadImage(currentIndex);
  }

  function bindControls() {
    lb.querySelector('.dox-lb-close').addEventListener('click', closeLightbox);
    lb.querySelector('.dox-lb-backdrop').addEventListener('click', closeLightbox);
    lb.querySelector('.dox-lb-prev').addEventListener('click', () => move(-1));
    lb.querySelector('.dox-lb-next').addEventListener('click', () => move(1));

    document.addEventListener('keydown', (e) => {
      if (!lb.classList.contains(ACTIVE_CLASS)) return;
      switch(e.key) {
        case 'Escape': closeLightbox(); break;
        case 'ArrowLeft': move(-1); break;
        case 'ArrowRight': move(1); break;
      }
    });

    // Touch swipe
    let startX = 0;
    lb.addEventListener('touchstart', (e) => { startX = e.touches[0].clientX; }, {passive: true});
    lb.addEventListener('touchend', (e) => {
      const diff = startX - e.changedTouches[0].clientX;
      if (Math.abs(diff) > 50) move(diff > 0 ? 1 : -1);
    }, {passive: true});

    window.addEventListener('popstate', (e) => {
      if (lb.classList.contains(ACTIVE_CLASS)) closeLightbox();
    });
  }

  // Initialize all queued containers
  if (window.doxLightboxQueue) {
    window.doxLightboxQueue.forEach(initLightbox);
    delete window.doxLightboxQueue;
  }

  // Expose for dynamic content
  window.doxLightboxInit = initLightbox;
})();
</script>

CSS: Structural Lightbox Styles

These styles use the CSS variable system and implement the blocky structural language matching the site's tool components.

/* ── Lightbox Container ── */
.dox-lb {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--transition-normal), visibility var(--transition-normal);
}

.dox-lb.open {
  opacity: 1;
  visibility: visible;
}

/* Prevent body scroll when open */
.dox-lb-hidden {
  overflow: hidden;
}

/* ── Backdrop ── */
.dox-lb-backdrop {
  position: absolute;
  inset: 0;
  background: var(--bg-base);
  opacity: 0.95;
  cursor: pointer;
}

/* ── Stage ── */
.dox-lb-stage {
  position: relative;
  z-index: 1;
  max-width: 94vw;
  max-height: 90vh;
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.dox-lb-img {
  max-width: 100%;
  max-height: 85vh;
  object-fit: contain;
  border: var(--card-border-w) solid var(--border-subtle);
  border-radius: var(--card-radius);
  box-shadow: var(--card-shadow) var(--border-subtle);
  background: var(--bg-surface);
}

.dox-lb-caption {
  margin-top: 16px;
  padding: 12px 24px;
  background: var(--bg-surface);
  border: var(--card-border-w) solid var(--border-subtle);
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  font-size: var(--text-sm);
  max-width: 600px;
  text-align: center;
}

/* ── Controls ── */
.dox-lb-close,
.dox-lb-nav {
  position: absolute;
  z-index: 2;
  background: var(--bg-surface);
  border: var(--card-border-w) solid var(--border-subtle);
  color: var(--text-primary);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background var(--transition-fast), transform var(--transition-fast);
}

.dox-lb-close {
  top: 16px;
  right: 16px;
  width: 48px;
  height: 48px;
  border-radius: var(--radius-sm);
  font-size: 28px;
  line-height: 1;
}

.dox-lb-nav {
  top: 50%;
  width: 56px;
  height: 56px;
  border-radius: var(--radius-full);
  font-size: 32px;
  margin-top: -28px;
}

.dox-lb-prev { left: 16px; }
.dox-lb-next { right: 16px; }

.dox-lb-close:hover,
.dox-lb-nav:hover {
  background: var(--bg-surface-2);
  transform: scale(1.05);
}

.dox-lb-close:active,
.dox-lb-nav:active {
  transform: scale(0.95);
}

/* ── Counter ── */
.dox-lb-counter {
  position: absolute;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  padding: 8px 20px;
  background: var(--bg-surface);
  border: var(--card-border-w) solid var(--border-subtle);
  border-radius: var(--radius-full);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  color: var(--text-muted);
}

/* ── Image Wrapper in Post ── */
.dox-lb-wrap {
  display: inline-block;
  position: relative;
}

.dox-lb-trigger {
  display: block;
  padding: 0;
  margin: 0;
  border: none;
  background: none;
  cursor: zoom-in;
  transition: transform var(--transition-fast);
}

.dox-lb-trigger:hover {
  transform: translate(-2px, -2px);
}

.dox-lb-trigger img {
  display: block;
  max-width: 100%;
  height: auto;
  border: var(--card-border-w) solid var(--border-subtle);
  border-radius: var(--card-radius);
}

Post-Render Alternative (For Dynamic Views)

If template editing is unavailable, use mutation observer to detect post-body injection:

<script>
(function() {
  'use strict';

  if (!window.MutationObserver) return;

  const observer = new MutationObserver((mutations) => {
    for (const m of mutations) {
      for (const node of m.addedNodes) {
        if (node.nodeType === 1 && node.matches && node.matches('.post-body')) {
          if (window.doxLightboxInit) {
            window.doxLightboxInit(node.id || (node.id = 'post-' + Date.now()));
          }
        }
      }
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });

  // Process existing
  document.querySelectorAll('.post-body').forEach(el => {
    if (window.doxLightboxInit) window.doxLightboxInit(el.id || (el.id = 'post-' + Date.now()));
  });
})();
</script>

Verification Methods

Test Procedure Expected Result
Click activation Click any post image Modal opens with image at full resolution
Keyboard close Press Escape Modal closes, body scroll restores
Back button Open lightbox, press browser back Modal closes without page navigation
Navigation Press ArrowRight / ArrowLeft Next/previous image loads with counter update
Touch swipe Swipe left on mobile Next image loads
Srcset resolution Inspect network on high-DPI display Appropriate density image requested

Troubleshooting

Images in links not lightboxing: The engine skips img elements inside <a> tags to preserve existing link behavior. To override, add data-lb-force and modify the selector.

Layout shift on open: Ensure img { max-width: 100%; height: auto; } is set. The clone inherits dimensions but explicit sizing prevents reflow.

Duplicate initialization on dynamic views: The doxLightboxQueue deduplicates via container ID. Verify unique IDs per post body container.

Related Tools

For batch image processing before upload, use the Bulk Image Converter to standardize formats. Verify structured data with the Schema Validator to ensure image markup indexes correctly.

Tools You Might Like

Handpicked utilities everyone is using right now