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:
- Click a photo to open it
- Swipe left or right to move between photos
- Arrow keys navigate between images (
←→) - Press Escape or tap outside the photo to close
- Read full post by clicking the caption title
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
- Copy the script below and paste it into your Bear footer, or on the page where you want to display the gallery.
- Copy the styles into your theme.
- Set a
meta_imageon 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">×</button>' +
'<button class="tagged-gallery-prev" type="button" aria-label="Previous image">‹</button>' +
'<button class="tagged-gallery-next" type="button" aria-label="Next image">›</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.
Requires JavaScript, available with Bear Blog subscription.↩