Robert Birming

Lightbox photo gallery

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 or mouse/touch:

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

<div class="bear-gallery">

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

</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.__bearGalleryLightbox) return
  window.__bearGalleryLightbox = true

  const thumbs = Array.from(document.querySelectorAll('.bear-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 = 'bear-gallery-lightbox'
  overlay.setAttribute('role', 'dialog')
  overlay.setAttribute('aria-modal', 'true')
  overlay.setAttribute('aria-hidden', 'true')
  overlay.tabIndex = -1

  overlay.innerHTML = `
    <button class="bear-gallery-close" type="button" aria-label="Close image">Close</button>
    <img alt="">
  `

  document.body.appendChild(overlay)

  const overlayImg = overlay.querySelector('img')
  const closeBtn = overlay.querySelector('.bear-gallery-close')

  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 || ''
    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()
    overlayImg.src = ''
    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()
  })

  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)
  })

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

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

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

Styles

/* Lightbox photo gallery | robertbirming.com */
.bear-gallery {
  margin-block: var(--space-block);
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 0.6rem;
}

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

.bear-gallery a {
  display: block;
}

/* Thumbnails */
.bear-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) {
  .bear-gallery img:hover {
    transform: scale(1.03);
  }
}

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

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

/* Lightbox overlay */
.bear-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;
}

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

/* The large image */
.bear-gallery-lightbox img {
  display: block;
  max-width: min(100%, 68.75rem);
  max-height: 88vh;
  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;
}

/* Close button */
.bear-gallery-close {
  position: absolute;
  inset-block-start: 0.9rem;
  inset-inline-end: 0.9rem;
  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) {
  .bear-gallery-close:hover {
    border-color: rgba(255, 255, 255, 0.6);
    background: rgba(0, 0, 0, 0.7);
  }
}

.bear-gallery-close:focus {
  outline: none;
}

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

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .bear-gallery-lightbox,
  .bear-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.