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'>×</button>
<button class='dox-lb-nav dox-lb-prev' aria-label='Previous image' data-dir='-1'>‹</button>
<button class='dox-lb-nav dox-lb-next' aria-label='Next image' data-dir='1'>›</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.