Robert Birming

Bearming gallery lightbox

A photo gallery with a built-in lightbox for browsing images in a larger view. The gallery grid works without any scripting — the lightbox effect requires a small footer script.1

Preview

Navigate with keyboard, buttons, or mouse/touch:

Captions are hidden in the grid and shown in the lightbox when an image has alt text.

You can adjust the image ratio in the CSS by changing the aspect-ratio value (e.g. 1 / 1 for square, 16 / 9 for cinematic).


How to use

Markup

Simple gallery, no captions:

<div class="bearming-gallery">

![Writing](image-url.webp)
![Coffee](image-url.webp)
![Blogging](image-url.webp)

</div>

With captions, the alt text and caption use the same text and appear in the lightbox:

<div class="bearming-gallery">

<figure>

![Stockholm](image-url.webp)

<figcaption>Stockholm</figcaption>
</figure>

<figure>

![Värmdö](image-url.webp)

<figcaption>Värmdö</figcaption>
</figure>

</div>

Installation

  1. Copy the script below and paste it into your Bear footer.
  2. Copy the styles into your theme.
  3. Add some photos and enjoy.

Script

<script>
document.addEventListener('DOMContentLoaded', function () {
  if (window.__bearingGalleryLightbox) return
  window.__bearingGalleryLightbox = true

  const thumbs = Array.from(document.querySelectorAll('.bearming-gallery img'))
  if (!thumbs.length) return

  thumbs.forEach(function (img) {
    if (!img.hasAttribute('tabindex')) img.setAttribute('tabindex', '0')
    if (!img.hasAttribute('role')) img.setAttribute('role', 'button')
    if (!img.hasAttribute('aria-label')) {
      const label = img.alt ? `Open image: ${img.alt}` : 'Open image'
      img.setAttribute('aria-label', label)
    }
  })

  const overlay = document.createElement('div')
  overlay.className = 'bearming-gallery-lightbox'
  overlay.setAttribute('role', 'dialog')
  overlay.setAttribute('aria-modal', 'true')
  overlay.setAttribute('aria-hidden', 'true')
  overlay.tabIndex = -1

  const hasMultiple = thumbs.length > 1

  overlay.innerHTML = `
    <button class="bearming-gallery-close" type="button" aria-label="Close image">Close</button>
    ${hasMultiple ? '<button class="bearming-gallery-prev" type="button" aria-label="Previous image">&#8592;</button>' : ''}
    <figure class="bearming-gallery-figure">
      <img alt="">
      <figcaption class="bearming-gallery-caption"></figcaption>
    </figure>
    ${hasMultiple ? '<button class="bearming-gallery-next" type="button" aria-label="Next image">&#8594;</button>' : ''}
  `

  document.body.appendChild(overlay)

  const overlayImg     = overlay.querySelector('.bearming-gallery-figure img')
  const overlayCaption = overlay.querySelector('.bearming-gallery-caption')
  const closeBtn       = overlay.querySelector('.bearming-gallery-close')
  const prevBtn        = overlay.querySelector('.bearming-gallery-prev')
  const nextBtn        = overlay.querySelector('.bearming-gallery-next')

  let currentIndex = -1
  let lastActiveEl = null
  let isLocked = false
  let prevHtmlOverflow = ''
  let prevBodyOverflow = ''

  function showOverlay() {
    overlay.classList.add('is-open')
    overlay.setAttribute('aria-hidden', 'false')
  }

  function hideOverlay() {
    overlay.classList.remove('is-open')
    overlay.setAttribute('aria-hidden', 'true')
  }

  function lockScroll() {
    if (isLocked) return
    prevHtmlOverflow = document.documentElement.style.overflow || ''
    prevBodyOverflow = document.body.style.overflow || ''
    document.documentElement.style.overflow = 'hidden'
    document.body.style.overflow = 'hidden'
    isLocked = true
  }

  function unlockScroll() {
    if (!isLocked) return
    document.documentElement.style.overflow = prevHtmlOverflow
    document.body.style.overflow = prevBodyOverflow
    isLocked = false
  }

  function updateImage(index) {
    const total = thumbs.length
    currentIndex = ((index % total) + total) % total

    const img = thumbs[currentIndex]
    overlayImg.src = img.currentSrc || img.src
    overlayImg.alt = img.alt || ''
    overlayCaption.textContent = img.alt || ''
    overlay.setAttribute('aria-label', `Image ${currentIndex + 1} of ${total}`)
  }

  function openAt(index) {
    if (currentIndex === -1) {
      lastActiveEl = document.activeElement
      lockScroll()
      showOverlay()
      overlay.focus({ preventScroll: true })
    }
    updateImage(index)
  }

  function closeLightbox(opts) {
    if (currentIndex === -1) return

    hideOverlay()
    currentIndex = -1
    unlockScroll()

    const skipFocus = opts && opts.skipFocus
    if (!skipFocus && lastActiveEl && typeof lastActiveEl.focus === 'function') {
      lastActiveEl.focus({ preventScroll: true })
    }
    lastActiveEl = null
  }

  function showNext(step) {
    if (currentIndex === -1) return
    updateImage(currentIndex + step)
  }

  thumbs.forEach(function (img, index) {
    img.addEventListener('click', function () {
      openAt(index)
    })

    img.addEventListener('keydown', function (event) {
      if (event.key === 'Enter' || event.key === ' ') {
        event.preventDefault()
        openAt(index)
      }
    })
  })

  closeBtn.addEventListener('click', function (event) {
    event.preventDefault()
    closeLightbox()
  })

  if (prevBtn) prevBtn.addEventListener('click', function (event) {
    event.stopPropagation()
    showNext(-1)
  })

  if (nextBtn) nextBtn.addEventListener('click', function (event) {
    event.stopPropagation()
    showNext(1)
  })

  overlayImg.addEventListener('click', function () {
    closeLightbox()
  })

  overlay.addEventListener('click', function (event) {
    if (event.target !== overlay) return

    const x = event.clientX
    const left = window.innerWidth * 0.33
    const right = window.innerWidth * 0.67

    if (x < left) showNext(-1)
    else if (x > right) showNext(1)
    else closeLightbox()
  })

  document.addEventListener('keydown', function (event) {
    if (currentIndex === -1) return

    if (event.key === 'Escape') {
      closeLightbox()
    } else if (event.key === 'ArrowRight' || event.key === 'd' || event.key === 'D') {
      showNext(1)
    } else if (event.key === 'ArrowLeft' || event.key === 'a' || event.key === 'A') {
      showNext(-1)
    } else if (event.key === 'Tab') {
      const focusable = Array.from(overlay.querySelectorAll('button, [tabindex="0"]'))
      const first = focusable[0]
      const last = focusable[focusable.length - 1]
      if (event.shiftKey) {
        if (document.activeElement === first || document.activeElement === overlay) {
          last.focus()
          event.preventDefault()
        }
      } else {
        if (document.activeElement === last) {
          first.focus()
          event.preventDefault()
        }
      }
    }
  })

  window.addEventListener('pagehide', function () {
    closeLightbox({ skipFocus: true })
  })

  window.addEventListener('pageshow', function () {
    unlockScroll()
    hideOverlay()
    currentIndex = -1
    lastActiveEl = null
  })

  document.addEventListener('visibilitychange', function () {
    if (document.visibilityState !== 'visible') closeLightbox({ skipFocus: true })
  })
}, { once: true })
</script>

Styles

/* Bearming gallery lightbox | robertbirming.com */

.bearming-gallery {
  margin-block: var(--space-block);
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 0.6rem;
}

.bearming-gallery > p {
  margin: 0;
  display: contents;
}

.bearming-gallery a {
  display: block;
}

/* Reset figure margin and hide caption in grid */

.bearming-gallery figure {
  margin: 0;
}

.bearming-gallery figcaption {
  display: none;
}

/* Thumbnails */

.bearming-gallery img {
  display: block;
  width: 100%;
  height: auto;
  margin: 0;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: var(--radius);
  cursor: pointer;
  user-select: none;
  -webkit-user-drag: none;
  transition: transform 0.15s ease;
}

@media (hover: hover) {
  .bearming-gallery img:hover {
    transform: scale(1.03);
  }
}

.bearming-gallery img:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 3px;
}

@media (min-width: 900px) {
  .bearming-gallery {
    grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
  }
}

/* Lightbox overlay */

.bearming-gallery-lightbox {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: grid;
  place-items: center;
  padding: 1.25rem;
  background: rgba(0, 0, 0, 0.86);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease;
}

.bearming-gallery-lightbox.is-open {
  opacity: 1;
  pointer-events: auto;
}

/* Image and caption wrapper */

.bearming-gallery-figure {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  margin: 0;
}

/* The large image */

.bearming-gallery-figure img {
  display: block;
  max-width: min(100%, 68.75rem);
  max-height: 82vh;
  width: auto;
  height: auto;
  border-radius: calc(var(--radius) * 2);
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
  cursor: zoom-out;
  image-rendering: auto;
}

/* Caption in lightbox */

.bearming-gallery-caption {
  color: rgba(255, 255, 255, 0.75);
  font-size: var(--font-small, 0.9em);
  font-style: italic;
  text-align: center;
  margin: 0;
}

.bearming-gallery-caption:empty {
  display: none;
}

/* Shared button styles */

.bearming-gallery-close,
.bearming-gallery-prev,
.bearming-gallery-next {
  position: absolute;
  padding: 0.35em 0.75em;
  border-radius: 999px;
  font: inherit;
  font-size: var(--font-small);
  line-height: 1.2;
  color: #fff;
  background: rgba(0, 0, 0, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.35);
  cursor: pointer;
}

@media (hover: hover) {
  .bearming-gallery-close:hover,
  .bearming-gallery-prev:hover,
  .bearming-gallery-next:hover {
    border-color: rgba(255, 255, 255, 0.6);
    background: rgba(0, 0, 0, 0.7);
  }
}

.bearming-gallery-close:focus,
.bearming-gallery-prev:focus,
.bearming-gallery-next:focus {
  outline: none;
}

.bearming-gallery-close:focus-visible,
.bearming-gallery-prev:focus-visible,
.bearming-gallery-next:focus-visible {
  border-color: rgba(255, 255, 255, 0.8);
  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.22);
}

/* Button positions */

.bearming-gallery-close {
  inset-block-start: 0.9rem;
  inset-inline-end: 0.9rem;
}

.bearming-gallery-prev {
  inset-inline-start: 0.9rem;
  top: 50%;
  transform: translateY(-50%);
}

.bearming-gallery-next {
  inset-inline-end: 0.9rem;
  top: 50%;
  transform: translateY(-50%);
}

/* Respect reduced motion */

@media (prefers-reduced-motion: reduce) {
  .bearming-gallery-lightbox,
  .bearming-gallery img {
    transition: none;
  }
}

Want more? Check out all available Bearming add-ons.

Happy blogging, picture by picture.

  1. Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.