Robert Birming

Bear Blog photo gallery (lightbox)

A photo gallery with a built-in lightbox for browsing images neatly in a larger view. Photos display in a grid by default and get dimmed on hover.

The gallery grid itself works without any scripting, but clicking a thumbnail opens it in an in-page lightbox instead of the browser's native image view, complete with captions, keyboard navigation, and smooth preloading between photos.1


Preview

Navigate with keyboard, mouse, or 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). The lightbox itself always shows the full image without cropping, whatever the grid ratio.

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 galleries = Array.from(document.querySelectorAll('.bear-gallery'))
  if (!galleries.length) return
  const photos = []
  galleries.forEach(function (root) {
    const imgs = Array.from(root.querySelectorAll('img'))
    const items = imgs.map(function (img) {
      const index = photos.length
      photos.push({ src: img.currentSrc || img.src, alt: img.alt || '' })
      img.loading = 'lazy'
      img.decoding = 'async'
      const btn = document.createElement('button')
      btn.type = 'button'
      btn.className = 'bear-gallery-item'
      btn.ariaLabel = img.alt ? `Open image: ${img.alt}` : 'Open image'
      btn.appendChild(img)
      btn.onclick = function () { openAt(index) }
      return btn
    })
    root.replaceChildren(...items)
  })
  if (!photos.length) return

  const dialog = document.createElement('dialog')
  dialog.className = 'bear-gallery-lightbox'
  dialog.innerHTML =
    '<button class="bear-gallery-close" type="button" aria-label="Close image">&times;</button>' +
    '<button class="bear-gallery-prev" type="button" aria-label="Previous image">&lsaquo;</button>' +
    '<button class="bear-gallery-next" type="button" aria-label="Next image">&rsaquo;</button>' +
    '<figure class="bear-gallery-figure"><img alt=""><figcaption class="bear-gallery-caption"></figcaption></figure>'
  document.body.appendChild(dialog)

  const lbImg = dialog.querySelector('img')
  const lbCaption = dialog.querySelector('figcaption')
  const closeBtn = dialog.querySelector('.bear-gallery-close')
  const prevBtn = dialog.querySelector('.bear-gallery-prev')
  const nextBtn = dialog.querySelector('.bear-gallery-next')
  let currentIndex = -1
  let lastActiveEl = null
  const preloaded = new Set()

  function preload(index) {
    const src = photos[index].src
    if (!src || preloaded.has(src)) return
    preloaded.add(src)
    const im = new Image()
    im.src = src
  }

  function updateLb(index) {
    const total = photos.length
    currentIndex = ((index % total) + total) % total
    const photo = photos[currentIndex]
    lbImg.src = photo.src
    lbImg.alt = photo.alt
    lbCaption.textContent = photo.alt
    lbCaption.hidden = !photo.alt
    dialog.setAttribute('aria-label', `Image ${currentIndex + 1} of ${total}`)
    preload((currentIndex + 1) % total)
    preload((currentIndex - 1 + total) % total)
  }

  function openAt(index) {
    lastActiveEl = document.activeElement
    updateLb(index)
    dialog.showModal()
  }

  dialog.addEventListener('close', function () {
    lbImg.removeAttribute('src')
    lbCaption.textContent = ''
    currentIndex = -1
    if (lastActiveEl && typeof lastActiveEl.focus === 'function') {
      lastActiveEl.focus({ preventScroll: true })
    }
    lastActiveEl = null
  })

  closeBtn.onclick = function () { dialog.close() }
  prevBtn.onclick = function () { updateLb(currentIndex - 1) }
  nextBtn.onclick = function () { updateLb(currentIndex + 1) }

  dialog.addEventListener('click', function (e) {
    if (e.target === dialog || e.target.classList.contains('bear-gallery-figure')) dialog.close()
  })

  dialog.addEventListener('keydown', function (e) {
    if (e.key === 'ArrowRight') updateLb(currentIndex + 1)
    if (e.key === 'ArrowLeft') updateLb(currentIndex - 1)
  })

  let touchStartX = 0
  let touchCurrentX = 0
  let isDragging = false
  let dragWidth = 0

  dialog.addEventListener('touchstart', function (e) {
    if (e.target.closest('.bear-gallery-close, .bear-gallery-prev, .bear-gallery-next')) {
      isDragging = false
      return
    }
    touchStartX = e.touches[0].clientX
    touchCurrentX = touchStartX
    isDragging = true
    dragWidth = lbImg.getBoundingClientRect().width
    lbImg.style.transition = 'none'
  }, { passive: true })

  dialog.addEventListener('touchmove', function (e) {
    if (!isDragging) return
    touchCurrentX = e.touches[0].clientX
    const dx = touchCurrentX - touchStartX
    lbImg.style.transform = `translateX(${dx}px)`
  }, { passive: true })

  dialog.addEventListener('touchend', function () {
    if (!isDragging) return
    isDragging = false
    const dx = touchCurrentX - touchStartX
    const threshold = dragWidth * 0.2
    lbImg.style.transition = 'transform 0.25s ease'
    if (Math.abs(dx) > threshold) {
      lbImg.style.transform = `translateX(${dx < 0 ? '-100%' : '100%'})`
      setTimeout(function () {
        updateLb(currentIndex + (dx < 0 ? 1 : -1))
        lbImg.style.transition = 'none'
        lbImg.style.transform = 'translateX(0)'
      }, 200)
    } else {
      lbImg.style.transform = 'translateX(0)'
    }
  }, { passive: true })
})
</script>

Styles

/* Bear gallery lightbox */
.bear-gallery {
  margin-block: 1.5rem;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 0.6rem;
}

.bear-gallery-item {
  display: block;
  width: 100%;
  overflow: hidden;
  aspect-ratio: 4 / 3;
  padding: 0;
  margin: 0;
  border: 0;
  background-color: transparent;
  cursor: pointer;
}

.bear-gallery-item img {
  display: block;
  width: 100%;
  height: 100%;
  margin: 0;
  object-fit: cover;
}

.bear-gallery-item:focus-visible {
  outline: 2px solid var(--link-color);
  outline-offset: 2px;
}

@media (hover: hover) {
  .bear-gallery-item:hover img {
    opacity: 0.7;
  }
}

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

.bear-gallery-lightbox {
  margin: 0;
  padding: 1.25rem;
  border: 0;
  background: transparent;
}

.bear-gallery-lightbox[open] {
  position: fixed;
  inset: 0;
  width: auto;
  height: auto;
  max-width: none;
  max-height: none;
  display: grid;
  place-items: center;
}

.bear-gallery-lightbox::backdrop {
  background: rgba(0, 0, 0, 0.86);
}

.bear-gallery-figure {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0;
  margin: 0;
  overflow: hidden;
}

.bear-gallery-figure img {
  max-width: 90vw;
  max-height: 80vh;
  width: auto;
  height: auto;
  object-fit: contain;
  margin: 0;
}

.bear-gallery-caption {
  margin-block-start: 0.75rem;
  color: rgba(255, 255, 255, 0.75);
  font-size: 0.9em;
  text-align: center;
}

.bear-gallery-close {
  position: absolute;
  inset-block-start: 0.9rem;
  inset-inline-end: 0.9rem;
  display: grid;
  place-items: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  border-radius: 50%;
  font-size: 2rem;
  line-height: 1;
  color: #fff;
  background: rgba(0, 0, 0, 0.55);
  border: none;
  outline: none;
  cursor: pointer;
}

.bear-gallery-prev,
.bear-gallery-next {
  display: none;
}

@media (hover: hover) {
  .bear-gallery-prev,
  .bear-gallery-next {
    position: absolute;
    inset-block: 0;
    display: grid;
    place-items: center;
    width: 3rem;
    padding: 0;
    border: none;
    background: transparent;
    color: #fff;
    font-size: 2.5rem;
    line-height: 1;
    cursor: pointer;
    opacity: 0.6;
    transition: opacity 0.15s ease;
  }

  .bear-gallery-prev {
    inset-inline-start: 0;
  }

  .bear-gallery-next {
    inset-inline-end: 0;
  }

  .bear-gallery-prev:hover,
  .bear-gallery-next:hover {
    opacity: 1;
  }
}

.bear-gallery-prev:focus-visible,
.bear-gallery-next:focus-visible {
  display: grid;
  place-items: center;
  position: absolute;
  inset-block: 0;
  width: 3rem;
  opacity: 1;
  outline: 2px solid #fff;
  outline-offset: -2px;
}

.bear-gallery-prev:focus-visible {
  inset-inline-start: 0;
}

.bear-gallery-next:focus-visible {
  inset-inline-end: 0;
}

@media (prefers-reduced-motion: reduce) {
  .bear-gallery-prev,
  .bear-gallery-next {
    transition: none;
  }
}

Want more? Check out the full Bear Blog library.

  1. Requires JavaScript, available with Bear Blog subscription.