Robert Birming

Bear Blog photo gallery (tagged)

Turn any tag into a photo gallery. This add-on builds a lightbox gallery straight from your tagged posts, no manual gallery upkeep needed.

It uses Bear's own {{ posts|tag:...|image:True }} embed to pull each tagged post's meta_image, so the gallery updates itself automatically whenever you publish a new post with that tag. Clicking a photo opens the lightbox, with the post's date and title as a link back to the full story.1


Preview

Navigate with keyboard, mouse, or touch:

Each post needs a meta_image set for it to show up here, Bear's tag filter with image:True only pulls posts that have one.

How to use

Markup

Wrap the embed in a tagged-gallery div, and swap photos for whichever tag you want to build a gallery from.

You can combine any of Bear's other post-embed filters here too, most usefully limit to cap how many photos show, and order:asc to show them oldest-first instead of newest-first:

<div class="tagged-gallery">

{{ posts|tag:photos|image:True|limit:12 }}

</div>

You can reuse this for as many tags as you like, a gallery for travel, another for family, each one just needs its own wrapper.

Installation

  1. Copy the script below and paste it into your Bear footer, or on the page where you want to display the gallery.
  2. Copy the styles into your theme.
  3. Set a meta_image on any post you want to appear, then add the markup wherever you want the gallery.

Script

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

  const items = Array.from(document.querySelectorAll('.tagged-gallery li'))
  if (!items.length) return

  const photos = []

  items.forEach(function (li) {
    const img = li.querySelector('img')
    if (!img) return
    const link = li.querySelector('a')
    const time = li.querySelector('time')
    const index = photos.length
    photos.push({
      src: img.currentSrc || img.src,
      title: link ? link.textContent.trim() : '',
      href: link ? link.href : '',
      date: time ? time.textContent.trim() : ''
    })
    img.loading = 'lazy'
    img.decoding = 'async'
    const btn = document.createElement('button')
    btn.type = 'button'
    btn.className = 'tagged-gallery-item'
    btn.ariaLabel = 'Open image: ' + (photos[index].title || 'photo')
    btn.appendChild(img)
    btn.onclick = function () { openAt(index) }
    li.insertBefore(btn, li.firstChild)
  })

  if (!photos.length) return

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

  const lbImg = dialog.querySelector('img')
  const lbCaption = dialog.querySelector('figcaption')
  const closeBtn = dialog.querySelector('.tagged-gallery-close')
  const prevBtn = dialog.querySelector('.tagged-gallery-prev')
  const nextBtn = dialog.querySelector('.tagged-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.title
    lbCaption.innerHTML = ''
    const dateSpan = document.createElement('span')
    dateSpan.className = 'tagged-gallery-date'
    dateSpan.textContent = photo.date
    const link = document.createElement('a')
    link.href = photo.href
    link.textContent = photo.title
    lbCaption.appendChild(dateSpan)
    lbCaption.appendChild(link)
    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()
    closeBtn.focus({ preventScroll: true })
  }

  dialog.addEventListener('close', function () {
    lbImg.removeAttribute('src')
    lbCaption.innerHTML = ''
    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.closest('.tagged-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('.tagged-gallery-close, .tagged-gallery-prev, .tagged-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 })

  dialog.addEventListener('touchcancel', function () {
    isDragging = false
    lbImg.style.transition = ''
    lbImg.style.transform = ''
  }, { passive: true })
})
</script>

Styles

/* Tagged photo gallery */
.tagged-gallery ul.blog-posts {
  list-style: none;
  margin-block: 1.5rem;
  padding: 0;
  width: 100%;
  max-width: 100%;
  box-sizing: border-box;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 0.6rem;
}

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

.tagged-gallery ul.blog-posts li {
  position: relative;
  display: block;
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
  margin: 0;
  padding: 0;
}

.tagged-gallery ul.blog-posts li::after {
  content: none;
}

.tagged-gallery-item {
  display: block;
  width: 100%;
  overflow: hidden;
  aspect-ratio: 4 / 3;
  padding: 0;
  margin: 0;
  border: 0;
  border-radius: 3px;
  cursor: pointer;
  box-sizing: border-box;
}

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

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

.tagged-gallery ul.blog-posts li > a {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.tagged-gallery ul.blog-posts li > span {
  position: absolute;
  inset-block-start: 0.4rem;
  inset-inline-end: 0.4rem;
  display: inline-flex;
  align-items: center;
  padding-block: 0.3rem;
  padding-inline: 0.4rem;
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(4px);
  border-radius: 999px;
  pointer-events: none;
  z-index: 2;
  line-height: 1;
}

.tagged-gallery ul.blog-posts li > span i,
.tagged-gallery ul.blog-posts li > span time {
  font-size: 0.6rem;
  font-style: normal;
  font-family: monospace;
  letter-spacing: 0.02em;
  color: #ffffff;
  opacity: 1;
}

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

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

.tagged-gallery-lightbox::backdrop {
  background: rgba(0, 0, 0, 0.88);
}

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

.tagged-gallery-figure img {
  display: block;
  max-width: 90vw;
  max-height: 80vh;
  width: auto;
  height: auto;
  object-fit: contain;
  margin: 0;
  border-radius: 4px;
}

.tagged-gallery-caption {
  margin-block-start: 0.85rem;
  font-size: calc(var(--font-scale) * 0.9);
  text-align: center;
}

.tagged-gallery-date {
  display: block;
  font-size: 0.9em;
  font-family: monospace;
  color: rgba(255, 255, 255, 0.65);
  margin-block-end: 0.2em;
}

.tagged-gallery-caption a {
  color: #fff;
  text-decoration: none;
  text-underline-offset: 0.2em;
}

.tagged-gallery-caption a:hover {
  text-decoration: underline;
}

.tagged-gallery-close {
  position: absolute;
  inset-block-start: 1rem;
  inset-inline-end: 1rem;
  z-index: 2;
  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;
}

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

@media (hover: hover) {
  .tagged-gallery-prev,
  .tagged-gallery-next {
    position: absolute;
    inset-block: 0;
    z-index: 1;
    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;
  }

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

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

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

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

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

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

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

Want more? Check out the full Bear Blog library.

  1. Requires JavaScript, available with Bear Blog subscription.