Lightbox Gallery
Tap a thumbnail and the picture zooms straight out of it to fill the screen over a dark backdrop. Swipe or use the arrows to move between pictures; swipe down, press Esc or tap the dark part and it shrinks back into its thumbnail.
Holiday album6 photos
How to use it
- Paste the snippet just before
</head>. - Add
data-lightbox-galleryto the element holding your thumbnails. - Give each
<img>analt, which becomes its caption. For a bigger version, wrap it in a link to the big file or adddata-full="big.jpg".
Options
Add any of these attributes to the same element.
data-duration | Time to zoom open. Default 520 ms. |
data-contained | Open inside the gallery’s parent instead of the whole window. Give the parent position: relative and overflow: hidden. |
data-autoplay | Contained only: opens a picture, flicks through a few and closes again by itself while in view, until someone touches it. A number sets the time per picture in ms. |
data-caption | On an image: a caption other than its alt text. |
The left and right arrow keys move between pictures, Tab stays inside the viewer, and focus goes back to the thumbnail when it closes. The page stops scrolling underneath while it’s open. It fires lightboxopen and lightboxclose events. From code: animacious.lightbox(gallery, 2) opens the third picture.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Lightbox Gallery: click a thumbnail and the picture zooms out of it to
// fill the screen over a dark backdrop. Swipe or use the arrows to move
// between pictures; swipe down, press Esc or tap the backdrop and it shrinks
// back into its thumbnail.
// Usage:
// <div data-lightbox-gallery>
// <a href="big/1.jpg"><img src="small/1.jpg" alt="Lavender hills at dawn"></a>
// <img src="2.jpg" data-full="big/2.jpg" alt="Harbour at dusk">
// …
// </div>
// The large picture comes from the link's href, data-full or the image
// itself, and the caption from its alt text (or data-caption).
// Optional: data-duration (ms to zoom open, default 520), data-contained
// (open inside the gallery's parent instead of the whole window; give the
// parent position: relative and overflow: hidden), data-autoplay (contained
// only: it opens, flicks through a few pictures and closes by itself while
// in view, until someone touches it).
// Events: "lightboxopen" and "lightboxclose" on the gallery.
// Script use: animacious.lightbox(gallery, 2) opens the third picture.
var DEFAULTS = { duration: 520, step: 1500 };
var css =
'.lbx-item{cursor:zoom-in}' +
'.lbx{position:fixed;inset:0;z-index:9200;visibility:hidden;touch-action:none;-webkit-user-select:none;user-select:none;font:600 14px/1.35 system-ui,sans-serif;color:#fff;text-align:center}' +
'.lbx.lbx-c{position:absolute;z-index:6}' +
'.lbx-bg{position:absolute;inset:0;background:rgba(10,8,18,.94);opacity:0}' +
'.lbx-view{position:absolute;inset:0;overflow:hidden}' +
'.lbx-track{display:flex;height:100%}' +
'.lbx-slide{flex:none;width:100%;height:100%;box-sizing:border-box;display:flex;align-items:center;justify-content:center;padding:58px 16px 52px}' +
'.lbx-c .lbx-slide{padding:40px 12px 34px}' +
'.lbx-slide img{display:block;max-width:100%;max-height:100%;border-radius:6px;transform-origin:0 0;-webkit-user-drag:none;box-shadow:0 20px 50px rgba(0,0,0,.4)}' +
'.lbx-ui{opacity:0}' +
'.lbx-top{position:absolute;left:0;right:0;top:0;display:flex;align-items:center;justify-content:space-between;padding:10px 12px 0 18px}' +
'.lbx-cap{position:absolute;left:16px;right:16px;bottom:14px;margin:0;font-weight:500;opacity:.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'.lbx-btn{display:grid;place-items:center;width:40px;height:40px;padding:0;border:0;border-radius:50%;background:rgba(255,255,255,.14);color:#fff;font:400 24px/1 system-ui,sans-serif;cursor:pointer}' +
'.lbx-btn:hover,.lbx-btn:focus-visible{background:rgba(255,255,255,.28)}' +
'.lbx-btn:focus-visible{outline:2px solid #fff;outline-offset:2px}' +
'.lbx-prev,.lbx-next{position:absolute;top:50%;margin-top:-20px}' +
'.lbx-prev{left:12px}.lbx-next{right:12px}' +
'.lbx-btn[disabled]{opacity:0;pointer-events:none}' +
'@media (hover:none){.lbx-prev,.lbx-next{display:none}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function el(tag, cls, html) {
var n = document.createElement(tag);
n.className = cls;
if (html) n.innerHTML = html;
return n;
}
function setup(gal) {
var d = gal.dataset;
var g = {
gal: gal, open: false, i: 0, busy: false,
duration: +d.duration || DEFAULTS.duration,
contained: on(d.contained),
deco: !!gal.closest('[aria-hidden="true"]'),
items: Array.from(gal.querySelectorAll('img'))
};
gal._lbx = g;
var btn = function (cls, html, label) {
var b = el(g.deco ? 'span' : 'button', 'lbx-btn ' + cls, html);
if (!g.deco) { b.type = 'button'; b.setAttribute('aria-label', label); }
return b;
};
var box = el('div', 'lbx' + (g.contained ? ' lbx-c' : ''));
if (!g.deco) {
box.setAttribute('role', 'dialog');
box.setAttribute('aria-modal', 'true');
box.setAttribute('aria-label', 'Picture viewer');
}
var bg = el('div', 'lbx-bg'), view = el('div', 'lbx-view'), track = el('div', 'lbx-track');
var top = el('div', 'lbx-top lbx-ui'), count = el('span', 'lbx-count');
var cap = el('p', 'lbx-cap lbx-ui');
var x = btn('lbx-x', '×', 'Close');
var prev = btn('lbx-prev lbx-ui', '‹', 'Previous picture');
var next = btn('lbx-next lbx-ui', '›', 'Next picture');
top.appendChild(count); top.appendChild(x);
view.appendChild(track);
[bg, view, top, cap, prev, next].forEach(function (n) { box.appendChild(n); });
g.slides = g.items.map(function (img) {
var s = el('div', 'lbx-slide'), big = new Image();
var a = img.closest('a');
if (a && !gal.contains(a)) a = null;
big.src = img.dataset.full || (a && a.href) || img.currentSrc || img.src;
big.alt = img.alt || '';
big.draggable = false;
s.appendChild(big);
track.appendChild(s);
return big;
});
(g.contained ? gal.parentNode : document.body).appendChild(box);
Object.assign(g, { box: box, bg: bg, view: view, track: track, count: count, cap: cap, x: x, prev: prev, next: next, ui: box.querySelectorAll('.lbx-ui') });
g.items.forEach(function (img, k) {
img.classList.add('lbx-item');
var link = img.closest('a, button');
if (!g.deco && !(link && gal.contains(link))) {
img.tabIndex = 0;
img.setAttribute('role', 'button');
img.setAttribute('aria-label', 'View ' + (img.alt || 'picture ' + (k + 1)));
}
});
gal.addEventListener('click', function (e) {
if (g.deco) return; // inside a link card: let the link work
var a = e.target.closest('a');
var img = e.target.closest('.lbx-item') || (a && gal.contains(a) && a.querySelector('.lbx-item'));
if (!img || g.items.indexOf(img) < 0) return;
e.preventDefault();
if (g.stopAuto && e.isTrusted) g.stopAuto();
show(g, g.items.indexOf(img), true);
});
gal.addEventListener('keydown', function (e) {
if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('lbx-item')) {
e.preventDefault();
show(g, g.items.indexOf(e.target), true);
}
});
x.addEventListener('click', function () { hide(g); });
prev.addEventListener('click', function () { go(g, g.i - 1); });
next.addEventListener('click', function () { go(g, g.i + 1); });
box.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { e.stopPropagation(); hide(g); }
else if (e.key === 'ArrowLeft') go(g, g.i - 1);
else if (e.key === 'ArrowRight') go(g, g.i + 1);
else if (e.key === 'Tab') trap(g, e);
});
drag(g);
new ResizeObserver(function () { if (g.open && !g.dragging) anime.utils.set(g.track, { x: -g.i * g.view.clientWidth }); }).observe(box);
if (on(d.autoplay) && g.contained && !reduced()) autoplay(g, +d.autoplay || DEFAULTS.step);
return g;
}
function trap(g, e) {
var f = [g.x, g.prev, g.next].filter(function (b) { return !b.disabled && b.offsetParent; });
var i = f.indexOf(document.activeElement);
e.preventDefault();
f[(i + (e.shiftKey ? -1 : 1) + f.length) % f.length].focus();
}
function label(g) {
var img = g.items[g.i];
g.count.textContent = (g.i + 1) + ' / ' + g.items.length;
g.cap.textContent = img.dataset.caption || img.alt || '';
g.prev.disabled = g.i === 0;
g.next.disabled = g.i === g.items.length - 1;
}
// The thumbnail's rectangle, as a transform on the large image.
function fromThumb(g, k) {
var big = g.slides[k], to = big.getBoundingClientRect(), fr = g.items[k].getBoundingClientRect();
if (!to.width || !fr.width) return null;
return { x: fr.left - to.left, y: fr.top - to.top, scaleX: fr.width / to.width, scaleY: fr.height / to.height };
}
function show(g, k, byUser) {
if (g.open || k < 0) return;
g.open = true;
g.i = k;
label(g);
g.box.style.visibility = 'visible';
anime.utils.set(g.track, { x: -k * g.view.clientWidth });
anime.utils.set(g.slides, { x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 1 });
if (!g.contained) document.documentElement.style.overflow = 'hidden';
g.gal.dispatchEvent(new CustomEvent('lightboxopen', { bubbles: true, detail: { index: k } }));
if (!g.deco && byUser) { g.back = document.activeElement; g.x.focus({ preventScroll: true }); }
anime.utils.remove([g.bg, g.ui]);
if (reduced()) { anime.utils.set([g.bg].concat(Array.from(g.ui)), { opacity: 1 }); return; }
var thumb = g.items[k], big = g.slides[k], t = fromThumb(g, k);
anime.animate(g.bg, { opacity: [0, 1], duration: g.duration * 0.6, ease: 'out(2)' });
anime.animate(g.ui, { opacity: [0, 1], duration: 300, delay: g.duration * 0.5 });
if (t) {
thumb.style.visibility = 'hidden';
anime.animate(big, {
x: [t.x, 0], y: [t.y, 0], scaleX: [t.scaleX, 1], scaleY: [t.scaleY, 1],
ease: anime.spring({ bounce: 0.18, duration: g.duration }),
onComplete: function () { thumb.style.visibility = ''; }
});
} else {
anime.animate(big, { scale: [0.85, 1], opacity: [0, 1], duration: g.duration, ease: 'out(3)' });
}
}
function hide(g) {
if (!g.open) return;
g.open = false;
var k = g.i, thumb = g.items[k], big = g.slides[k];
if (!g.contained) document.documentElement.style.overflow = '';
if (g.box.contains(document.activeElement)) {
if (g.back && g.back.focus && g.back !== document.body) g.back.focus({ preventScroll: true });
else document.activeElement.blur();
}
g.gal.dispatchEvent(new CustomEvent('lightboxclose', { bubbles: true, detail: { index: k } }));
var done = function () {
if (g.open) return;
g.box.style.visibility = 'hidden';
thumb.style.visibility = '';
anime.utils.set(big, { x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 1 });
};
anime.utils.remove([g.bg, g.ui, big]);
if (reduced()) { anime.utils.set([g.bg].concat(Array.from(g.ui)), { opacity: 0 }); return done(); }
anime.animate(g.ui, { opacity: 0, duration: 160 });
anime.animate(g.bg, { opacity: 0, duration: g.duration * 0.7, ease: 'out(2)' });
// Back into the thumbnail if it's on screen, otherwise just fade away.
var cur = { x: anime.utils.get(big, 'x', false) || 0, y: anime.utils.get(big, 'y', false) || 0 };
anime.utils.set(big, { x: 0, y: 0, scaleX: 1, scaleY: 1 });
var t = fromThumb(g, k), fr = thumb.getBoundingClientRect();
var onScreen = t && fr.bottom > 0 && fr.top < window.innerHeight;
anime.utils.set(big, cur);
if (onScreen) {
thumb.style.visibility = 'hidden';
anime.animate(big, { x: t.x, y: t.y, scaleX: t.scaleX, scaleY: t.scaleY, duration: g.duration * 0.8, ease: 'inOut(3)', onComplete: done });
} else {
anime.animate(big, { scaleX: 0.9, scaleY: 0.9, opacity: 0, duration: 300, ease: 'in(2)', onComplete: done });
}
}
function go(g, n) {
var w = g.view.clientWidth;
n = Math.max(0, Math.min(g.items.length - 1, n));
g.i = n;
label(g);
anime.utils.remove(g.track);
if (reduced()) return anime.utils.set(g.track, { x: -n * w });
anime.animate(g.track, { x: -n * w, ease: anime.spring({ bounce: 0.1, duration: 480 }) });
}
// Swipe sideways to move between pictures, or down to close.
function drag(g) {
var p = null;
g.view.addEventListener('pointerdown', function (e) {
if (!g.open || e.button > 0) return;
if (g.stopAuto && e.isTrusted) g.stopAuto();
p = { x: e.clientX, y: e.clientY, t: Date.now(), axis: null, base: -g.i * g.view.clientWidth, dx: 0, dy: 0 };
g.view.setPointerCapture(e.pointerId);
});
g.view.addEventListener('pointermove', function (e) {
if (!p) return;
p.dx = e.clientX - p.x; p.dy = e.clientY - p.y;
if (!p.axis && Math.abs(p.dx) + Math.abs(p.dy) > 8) {
p.axis = Math.abs(p.dx) > Math.abs(p.dy) ? 'x' : 'y';
g.dragging = true;
anime.utils.remove([g.track, g.slides[g.i], g.bg]);
}
if (p.axis === 'x') {
var edge = (g.i === 0 && p.dx > 0) || (g.i === g.items.length - 1 && p.dx < 0);
anime.utils.set(g.track, { x: p.base + (edge ? p.dx * 0.3 : p.dx) });
} else if (p.axis === 'y') {
var k = Math.min(1, Math.abs(p.dy) / 320);
anime.utils.set(g.slides[g.i], { y: p.dy, scaleX: 1 - k * 0.25, scaleY: 1 - k * 0.25 });
anime.utils.set(g.bg, { opacity: 1 - k * 0.8 });
}
});
function end(e) {
if (!p) return;
var q = p, v = q.dx / Math.max(1, Date.now() - q.t);
p = null;
g.dragging = false;
if (!q.axis) {
// A tap on the dark part closes it.
if (e.type === 'pointerup' && !e.target.closest('img')) hide(g);
return;
}
if (q.axis === 'x') {
var w = g.view.clientWidth, n = g.i;
if (q.dx < -w * 0.18 || v < -0.45) n++;
else if (q.dx > w * 0.18 || v > 0.45) n--;
go(g, n);
} else if (Math.abs(q.dy) > 90) {
hide(g);
} else {
anime.animate(g.slides[g.i], { y: 0, scaleX: 1, scaleY: 1, ease: anime.spring({ bounce: 0.3, duration: 400 }) });
anime.animate(g.bg, { opacity: 1, duration: 300 });
}
}
g.view.addEventListener('pointerup', end);
g.view.addEventListener('pointercancel', end);
}
// Demo mode: open a picture, flick through a couple, close, repeat.
function autoplay(g, step) {
var timers = [], visible = false, live = true, start = 0;
function later(fn, ms) { timers.push(setTimeout(fn, ms)); }
function stop() { timers.forEach(clearTimeout); timers = []; }
function run() {
stop();
if (!visible || !live) return;
var k = start % Math.max(1, g.items.length - 2);
start++;
later(function () {
anime.animate(g.items[k], { scale: [1, 0.94, 1], duration: 260, ease: 'out(2)' });
later(function () { show(g, k); }, 160);
later(function () { go(g, k + 1); }, 160 + step);
later(function () { go(g, k + 2); }, 160 + step * 2);
later(function () { hide(g); }, 160 + step * 3);
later(run, 160 + step * 3 + 600);
}, 1100);
}
g.stopAuto = function () { if (!live) return; live = false; stop(); };
g.gal.parentNode.addEventListener('pointerdown', function (e) { if (e.isTrusted && g.stopAuto) g.stopAuto(); });
new IntersectionObserver(function (entries) {
visible = entries[entries.length - 1].isIntersecting;
if (visible) { if (!g.open) run(); }
else { stop(); hide(g); }
}, { threshold: 0.4 }).observe(g.gal.parentNode);
}
function init() {
document.querySelectorAll('[data-lightbox-gallery]').forEach(function (gal) {
if (gal._lbx) return;
setup(gal);
});
}
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
document.querySelectorAll('[data-lightbox-gallery]').forEach(function (gal) { if (gal._lbx && !gal._lbx.contained) hide(gal._lbx); });
});
window.animacious = window.animacious || {};
window.animacious.lightbox = function (gal, k) {
var g = gal._lbx || setup(gal);
show(g, k || 0, true);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-lightbox-gallery>
<a href="big/hills.jpg"><img src="small/hills.jpg" alt="Lavender hills at dawn"></a>
<a href="big/ridge.jpg"><img src="small/ridge.jpg" alt="Dusk over the ridge"></a>
<img src="meadow.jpg" alt="Spring meadow">
</div>