← Overlays

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

Lavender hills at dawn Dusk over the ridge Spring meadow Desert sunset Moonrise Lagoon at noon

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-lightbox-gallery to the element holding your thumbnails.
  3. Give each <img> an alt, which becomes its caption. For a bigger version, wrap it in a link to the big file or add data-full="big.jpg".

Options

Add any of these attributes to the same element.

data-durationTime to zoom open. Default 520 ms.
data-containedOpen inside the gallery’s parent instead of the whole window. Give the parent position: relative and overflow: hidden.
data-autoplayContained 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-captionOn 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.

1 · Snippet
<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', '&times;', 'Close');
      var prev = btn('lbx-prev lbx-ui', '&lsaquo;', 'Previous picture');
      var next = btn('lbx-next lbx-ui', '&rsaquo;', '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>
2 · Your HTML
<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>