Robert Birming

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>

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">&#8592;</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">&#8594;</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.

  1. Built for the Bearming theme. Using a different theme? Add the Bearming tokens to make them work with your setup.

  2. Requires JavaScript, available on Bear Blog's premium plan.