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:
- Click an image to open it
- Use the ← → buttons to navigate, or click the left/right sides of the screen
- Arrow keys work too (
←→, orAD) - Press Escape to close
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">



</div>
With captions, the alt text and caption use the same text and appear in the lightbox:
<div class="bearming-gallery">
<figure>

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

<figcaption>Värmdö</figcaption>
</figure>
</div>
Installation
- Copy the script below and paste it into your Bear footer.
- Copy the styles into your theme.
- 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">←</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">→</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.
Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.↩