Bluesky photo gallery
A dynamic photo gallery that pulls images directly from any Bluesky account. Supports three views: Grid, List, Feed, plus caption search and a full lightbox.1 2
Preview
Head over to my photos page to see a live demo.
How to use
Markup
Add this wherever you want the gallery to appear, replacing the handle with your own:
<div class="bsky-gallery" data-handle="your-handle.bsky.social" data-limit="24" hidden></div>
data-handle— your Bluesky handle (e.g.baloo.bsky.socialor a custom domain likerobertbirming.com)data-limit— maximum number of photos to show (default:24)
Script
Copy the script below and paste it into your Bear footer.
<script>
/* Bluesky photo gallery | robertbirming.com */
(async () => {
const root = document.querySelector('.bsky-gallery');
const handle = root?.getAttribute('data-handle');
const limit = parseInt(root?.getAttribute('data-limit') || '24', 10);
if (!handle) return;
try {
const base = 'https://public.api.bsky.app/xrpc';
const res = await fetch(`${base}/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(handle)}&limit=100&filter=posts_with_media`);
if (!res.ok) return;
const data = await res.json();
const posts = data.feed?.map(f => f.post) || [];
if (!posts.length) return;
const photos = [];
for (const post of posts) {
if (post.embed?.$type !== 'app.bsky.embed.images#view') continue;
const caption = post.record?.text || '';
const postUrl = `https://bsky.app/profile/${handle}/post/${post.uri.split('/').pop()}`;
for (const img of post.embed.images) {
photos.push({ src: img.fullsize, thumb: img.thumb, alt: img.alt || '', caption, postUrl });
if (photos.length >= limit) break;
}
if (photos.length >= limit) break;
}
if (!photos.length) return;
const list = (() => {
const el = document.createElement('div');
el.className = 'bsky-gallery-list';
root.appendChild(el);
return el;
})();
const overlay = (() => {
const el = document.createElement('div');
el.className = 'bsky-gallery-lightbox';
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-hidden', 'true');
el.tabIndex = -1;
el.innerHTML =
'<button class="bsky-lightbox-close" type="button" aria-label="Close image">Close</button>' +
'<button class="bsky-lightbox-prev" type="button" aria-label="Previous image">←</button>' +
'<figure class="bsky-lightbox-figure">' +
'<img alt="">' +
'<figcaption class="bsky-lightbox-caption" id="bsky-lightbox-caption"></figcaption>' +
'</figure>' +
'<button class="bsky-lightbox-next" type="button" aria-label="Next image">→</button>';
document.body.appendChild(el);
return el;
})();
overlay.setAttribute('aria-describedby', 'bsky-lightbox-caption');
const lbImg = overlay.querySelector('.bsky-lightbox-figure img');
const lbCaption = overlay.querySelector('.bsky-lightbox-caption');
const lbClose = overlay.querySelector('.bsky-lightbox-close');
const lbPrev = overlay.querySelector('.bsky-lightbox-prev');
const lbNext = overlay.querySelector('.bsky-lightbox-next');
const lbFig = overlay.querySelector('.bsky-lightbox-figure');
let currentIndex = -1;
let lastActiveEl = null;
let isLocked = false;
let prevHtmlOverflow = '';
let prevBodyOverflow = '';
function getVisible() {
return [...list.querySelectorAll('.bsky-gallery-item:not([hidden])')];
}
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 updateLb(index) {
const visible = getVisible();
const total = visible.length;
if (!total) return;
currentIndex = ((index % total) + total) % total;
const item = visible[currentIndex];
lbImg.src = item.dataset.src;
lbImg.alt = item.dataset.alt;
lbCaption.textContent = item.dataset.alt || item.dataset.caption || '';
overlay.setAttribute('aria-label', `Image ${currentIndex + 1} of ${total}`);
}
function openAt(index) {
if (currentIndex === -1) {
lastActiveEl = document.activeElement;
lockScroll();
showOverlay();
overlay.focus({ preventScroll: true });
}
updateLb(index);
}
function closeLb(opts) {
if (currentIndex === -1) return;
hideOverlay();
lbImg.src = '';
lbCaption.textContent = '';
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;
updateLb(currentIndex + step);
}
lbClose.addEventListener('click', e => { e.preventDefault(); closeLb(); });
lbPrev.addEventListener('click', e => { e.stopPropagation(); showNext(-1); });
lbNext.addEventListener('click', e => { e.stopPropagation(); showNext(1); });
overlay.addEventListener('click', e => {
if (lbFig.contains(e.target) && e.target !== lbFig) return;
const x = e.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 closeLb();
});
document.addEventListener('keydown', e => {
if (currentIndex === -1) return;
if (e.key === 'Escape') { closeLb(); return; }
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') { showNext(1); return; }
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') { showNext(-1); return; }
if (e.key === 'Tab') {
const focusable = [...overlay.querySelectorAll('button, [tabindex="0"]')];
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first || document.activeElement === overlay) { last.focus(); e.preventDefault(); }
} else {
if (document.activeElement === last) { first.focus(); e.preventDefault(); }
}
}
});
window.addEventListener('pagehide', () => closeLb({ skipFocus: true }));
window.addEventListener('pageshow', () => { unlockScroll(); hideOverlay(); lbImg.src = ''; lbCaption.textContent = ''; currentIndex = -1; lastActiveEl = null; });
document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') closeLb({ skipFocus: true }); });
const controls = document.createElement('div');
controls.className = 'bsky-gallery-controls';
const selectWrap = document.createElement('div');
selectWrap.className = 'bsky-gallery-select';
const select = document.createElement('select');
select.setAttribute('aria-label', 'Switch view');
[['grid', 'Grid'], ['list', 'List'], ['feed', 'Feed']].forEach(([val, label]) => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = label;
select.appendChild(opt);
});
selectWrap.appendChild(select);
const search = document.createElement('input');
search.type = 'search';
search.placeholder = 'Search photos…';
search.setAttribute('aria-label', 'Search photos');
controls.appendChild(selectWrap);
controls.appendChild(search);
root.insertBefore(controls, list);
const count = document.createElement('p');
count.className = 'bsky-gallery-count';
count.hidden = true;
root.insertBefore(count, list);
list.dataset.view = 'grid';
const fragment = document.createDocumentFragment();
photos.forEach(photo => {
const item = document.createElement('div');
item.className = 'bsky-gallery-item';
item.dataset.src = photo.src;
item.dataset.alt = photo.alt;
item.dataset.caption = photo.caption;
item.dataset.text = ((photo.caption || '') + ' ' + (photo.alt || '')).toLowerCase().trim();
const btn = document.createElement('button');
btn.setAttribute('aria-label', photo.alt || 'Open photo');
btn.addEventListener('click', () => {
const visible = getVisible();
const idx = visible.indexOf(item);
if (idx !== -1) openAt(idx);
});
const img = document.createElement('img');
img.src = photo.thumb;
img.alt = photo.alt;
img.loading = 'lazy';
img.decoding = 'async';
btn.appendChild(img);
item.appendChild(btn);
if (photo.caption) {
const cap = document.createElement('p');
cap.className = 'bsky-gallery-caption';
cap.textContent = photo.caption;
item.appendChild(cap);
}
fragment.appendChild(item);
});
list.appendChild(fragment);
function applySearch(q) {
let visible = 0;
list.querySelectorAll('.bsky-gallery-item').forEach(item => {
const match = !q || item.dataset.text.includes(q);
item.hidden = !match;
if (match) visible++;
});
let empty = list.querySelector('.bsky-gallery-empty');
if (!visible) {
if (!empty) {
empty = document.createElement('p');
empty.className = 'bearming-panel-empty bsky-gallery-empty';
list.appendChild(empty);
}
empty.textContent = 'No photos match your search.';
count.hidden = true;
} else {
if (empty) empty.remove();
if (q) {
count.textContent = `${visible} of ${photos.length} photos`;
count.hidden = false;
} else {
count.hidden = true;
}
}
}
select.addEventListener('change', () => { list.dataset.view = select.value; });
let debounce = null;
search.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => applySearch(search.value.toLowerCase().trim()), 140);
});
root.removeAttribute('hidden');
} catch {}
})();
</script>
Styles
/* Bluesky photo gallery | robertbirming.com */
.bsky-gallery-controls {
display: flex;
align-items: center;
gap: 0.8rem;
margin-block: 0 0.9rem;
}
.bsky-gallery-select {
position: relative;
flex: 0 1 8rem;
}
.bsky-gallery-select select {
width: 100%;
padding-block: 0.55em;
padding-inline: 0.7em 2.2em;
font: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
outline: none;
appearance: none;
}
.bsky-gallery-select select:focus-visible {
border-color: color-mix(in srgb, var(--link) 45%, var(--bg));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--link) 18%, transparent);
}
.bsky-gallery-select::after {
content: "";
position: absolute;
inset-block-start: 50%;
inset-inline-end: 0.9em;
width: 0.5em;
height: 0.5em;
border-inline-end: 2px solid var(--text);
border-block-end: 2px solid var(--text);
transform: translateY(-60%) rotate(45deg);
pointer-events: none;
opacity: 0.9;
}
.bsky-gallery-controls input[type="search"] {
flex: 1;
padding-block: 0.55em;
padding-inline: 0.7em;
font: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
outline: none;
}
.bsky-gallery-controls input[type="search"]:focus-visible {
border-color: color-mix(in srgb, var(--link) 45%, var(--bg));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--link) 18%, transparent);
}
.bsky-gallery-count {
margin-block: 0 0.9rem;
font-size: var(--font-small);
color: var(--muted);
}
/* Grid view */
.bsky-gallery-list[data-view="grid"] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.5rem;
margin-block: 0 var(--space-block);
}
.bsky-gallery-list[data-view="grid"] .bsky-gallery-item {
overflow: hidden;
aspect-ratio: 4 / 3;
border-radius: var(--radius);
}
.bsky-gallery-list[data-view="grid"] .bsky-gallery-item button {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.bsky-gallery-list[data-view="grid"] .bsky-gallery-item img {
width: 100%;
height: 100%;
margin: 0;
object-fit: cover;
transition: transform 0.2s ease;
}
@media (hover: hover) {
.bsky-gallery-list[data-view="grid"] .bsky-gallery-item button:hover img {
transform: scale(1.04);
}
}
.bsky-gallery-list[data-view="grid"] .bsky-gallery-caption {
display: none;
}
/* List view */
.bsky-gallery-list[data-view="list"] {
display: flex;
flex-direction: column;
gap: calc(var(--space-block) * 0.75);
margin-block: 0 var(--space-block);
}
.bsky-gallery-list[data-view="list"] .bsky-gallery-item {
display: flex;
gap: 1.2rem;
align-items: flex-start;
padding: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.bsky-gallery-list[data-view="list"] .bsky-gallery-item button {
flex: 0 0 9rem;
padding: 0;
border: 0;
background: none;
cursor: pointer;
border-radius: calc(var(--radius) * 0.6);
overflow: hidden;
aspect-ratio: 4 / 3;
}
.bsky-gallery-list[data-view="list"] .bsky-gallery-item img {
width: 100%;
height: 100%;
margin: 0;
object-fit: cover;
transition: transform 0.2s ease;
}
@media (hover: hover) {
.bsky-gallery-list[data-view="list"] .bsky-gallery-item button:hover img {
transform: scale(1.04);
}
}
.bsky-gallery-list[data-view="list"] .bsky-gallery-caption {
flex: 1;
margin: 0;
font-size: var(--font-small);
color: var(--text);
line-height: 1.55;
}
/* Feed view */
.bsky-gallery-list[data-view="feed"] {
display: flex;
flex-direction: column;
gap: calc(var(--space-block) * 0.75);
margin-block: 0 var(--space-block);
}
.bsky-gallery-list[data-view="feed"] .bsky-gallery-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.bsky-gallery-list[data-view="feed"] .bsky-gallery-caption {
padding: 0.8rem 1rem;
margin: 0;
font-size: var(--font-small);
color: var(--text);
line-height: 1.55;
}
.bsky-gallery-list[data-view="feed"] .bsky-gallery-item button {
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.bsky-gallery-list[data-view="feed"] .bsky-gallery-item img {
display: block;
width: 100%;
height: auto;
margin: 0;
transition: opacity 0.2s ease;
}
@media (hover: hover) {
.bsky-gallery-list[data-view="feed"] .bsky-gallery-item button:hover img {
opacity: 0.9;
}
}
/* Lightbox */
.bsky-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;
}
.bsky-gallery-lightbox.is-open {
opacity: 1;
pointer-events: auto;
}
.bsky-lightbox-figure {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
margin: 0;
}
.bsky-lightbox-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;
margin: 0;
}
.bsky-lightbox-caption {
color: rgba(255, 255, 255, 0.75);
font-size: var(--font-small, 0.9em);
font-style: italic;
text-align: center;
margin: 0;
}
.bsky-lightbox-caption:empty {
display: none;
}
.bsky-lightbox-close,
.bsky-lightbox-prev,
.bsky-lightbox-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) {
.bsky-lightbox-close:hover,
.bsky-lightbox-prev:hover,
.bsky-lightbox-next:hover {
border-color: rgba(255, 255, 255, 0.6);
background: rgba(0, 0, 0, 0.7);
}
}
.bsky-lightbox-close:focus,
.bsky-lightbox-prev:focus,
.bsky-lightbox-next:focus {
outline: none;
}
.bsky-lightbox-close:focus-visible,
.bsky-lightbox-prev:focus-visible,
.bsky-lightbox-next:focus-visible {
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.22);
}
.bsky-lightbox-close {
inset-block-start: 0.9rem;
inset-inline-end: 0.9rem;
}
.bsky-lightbox-prev {
inset-inline-start: 0.9rem;
top: 50%;
transform: translateY(-50%);
}
.bsky-lightbox-next {
inset-inline-end: 0.9rem;
top: 50%;
transform: translateY(-50%);
}
@media (prefers-reduced-motion: reduce) {
.bsky-gallery-lightbox,
.bsky-gallery-list[data-view="grid"] .bsky-gallery-item img,
.bsky-gallery-list[data-view="list"] .bsky-gallery-item img,
.bsky-gallery-list[data-view="feed"] .bsky-gallery-item img {
transition: none;
}
}
Happy blogging, picture by picture.
Want more? Check out all available Bearming add-ons.
Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.↩
Requires JavaScript, available on Bear Blog's premium plan.↩