Bluesky feed
Display your latest Bluesky posts as a scrollable feed of cards. Links, @mentions, and #hashtags are fully clickable. Supports images, videos, link cards, quote posts, and has built-in display support for Popfeed reviews and Beacon Bits check-ins.1 2
Preview
Head over to my status page to see a live demo.
How to use
Markup
Paste this where you want the feed to appear, and update data-handle to your Bluesky handle. Use data-limit to control how many posts to show (default: 10).
<div class="bsky-feed" data-handle="your-handle.bsky.social" data-limit="10" hidden></div>
<script>
/* Bluesky feed | robertbirming.com */
(async () => {
const root = document.querySelector('.bsky-feed');
const handle = root?.getAttribute('data-handle');
const limit = parseInt(root?.getAttribute('data-limit') || '10', 10);
if (!handle) return;
try {
const base = 'https://public.api.bsky.app/xrpc';
const fetchLimit = Math.min(limit * 2, 100);
const res = await fetch(`${base}/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(handle)}&limit=${fetchLimit}&filter=posts_no_replies`);
if (!res.ok) return;
const data = await res.json();
const posts = data.feed?.filter(f => !f.reason).map(f => f.post)
.slice(0, limit) || [];
if (!posts.length) return;
const esc = s => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
const enc = new TextEncoder();
const dec = new TextDecoder();
function isBeacon(text) {
return /beaconbits\.app\/beacon\//i.test(text);
}
function parseBeaconLines(text) {
const lines = text.split('\n');
let name = '', meta = '', rating = 0, weather = '';
for (const line of lines) {
if (/^📍/.test(line)) name = line.replace(/^📍\s*/, '').trim();
if (/·/.test(line) && !meta) meta = line.trim();
if (/^[★☆]+$/.test(line.trim())) {
rating = (line.match(/★/g) || []).length;
}
if (/^[\u{1F300}-\u{1F5FF}\u{2600}-\u{26FF}]/u.test(line) && /°[CF]/.test(line)) {
weather = line.trim();
}
}
return { name, meta, rating, weather };
}
function cleanBeaconText(text) {
let t = text;
t = t.replace(/^📍[^\n]+\n?/m, '');
t = t.replace(/^[^\n]+·[^\n]+\n?/m, '');
t = t.replace(/^[\u{1F300}-\u{1F5FF}\u{2600}-\u{26FF}][^\n]*°[CF][^\n]*\n?/mu, '');
t = t.replace(/^[★☆✦]+\s*\n?/m, '');
t = t.replace(/\n+https?:\/\/beaconbits\.app\/\S+/gi, '').trimEnd();
t = t.replace(/\n*#BeaconBits\s*/gi, '').trimEnd();
return t.trim();
}
function beaconUrl(text) {
const m = text.match(/https?:\/\/beaconbits\.app\/\S+/i);
return m ? m[0] : null;
}
function stars(n) {
return '★'.repeat(n) + '☆'.repeat(5 - n);
}
function cleanText(text, embed) {
let t = text;
try {
if (embed?.$type === 'app.bsky.embed.external#view' && embed.external?.uri) {
const escaped = embed.external.uri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
t = t.replace(new RegExp('\\n*' + escaped + '\\s*$'), '').trimEnd();
}
} catch {}
t = t.replace(/\n+https?:\/\/\S+\s*$/, '').trimEnd();
t = t.replace(/\n*View .+ on @\S+\s*$/i, '').trimEnd();
t = t.replace(/\n*via @\S+\s*$/i, '').trimEnd();
return t;
}
function applyFacets(text, facets) {
if (!facets?.length) return '<p>' + esc(text).replace(/\n\n+/g,'</p><p>').replace(/\n/g,'<br>') + '</p>';
const sorted = [...facets].sort((a, b) => b.index.byteStart - a.index.byteStart);
let bytes = enc.encode(text);
sorted.forEach(facet => {
const { byteStart, byteEnd } = facet.index;
if (byteStart < 0 || byteEnd > bytes.length || byteStart >= byteEnd) return;
const chunk = dec.decode(bytes.slice(byteStart, byteEnd));
const feature = facet.features?.[0];
let replacement = esc(chunk);
if (feature?.$type === 'app.bsky.richtext.facet#link') {
replacement = `<a href="${esc(feature.uri)}" target="_blank" rel="noopener noreferrer">${esc(chunk)}</a>`;
} else if (feature?.$type === 'app.bsky.richtext.facet#mention') {
replacement = `<a href="https://bsky.app/profile/${esc(feature.did)}" target="_blank" rel="noopener noreferrer">${esc(chunk)}</a>`;
} else if (feature?.$type === 'app.bsky.richtext.facet#tag') {
replacement = `<a href="https://bsky.app/hashtag/${esc(feature.tag)}" target="_blank" rel="noopener noreferrer">${esc(chunk)}</a>`;
}
const repBytes = enc.encode(replacement);
const merged = new Uint8Array(bytes.length - (byteEnd - byteStart) + repBytes.length);
merged.set(bytes.slice(0, byteStart));
merged.set(repBytes, byteStart);
merged.set(bytes.slice(byteEnd), byteStart + repBytes.length);
bytes = merged;
});
return '<p>' + dec.decode(bytes).replace(/\n\n+/g,'</p><p>').replace(/\n/g,'<br>') + '</p>';
}
const ago = (n, u) => n + ' ' + u + (n === 1 ? '' : 's') + ' ago';
function relativeTime(dateStr, now) {
const mins = Math.max(0, (now - new Date(dateStr)) / 60000) | 0;
return mins < 1 ? 'just now'
: mins < 60 ? ago(mins, 'minute')
: mins < 1440 ? ago(mins / 60 | 0, 'hour')
: mins < 43200 ? ago(mins / 1440 | 0, 'day')
: mins < 525600 ? ago(mins / 43200 | 0, 'month')
: ago(mins / 525600 | 0, 'year');
}
const list = document.createElement('div');
list.className = 'bsky-feed-list';
const now = Date.now();
posts.forEach(post => {
const rkey = post.uri.split('/').pop();
const postUrl = `https://bsky.app/profile/${handle}/post/${rkey}`;
const when = relativeTime(post.record.createdAt, now);
const embed = post.embed;
const rawText = post.record.text;
const beacon = isBeacon(rawText);
let contentHTML = '';
let embedHTML = '';
if (beacon) {
const { name, meta, rating, weather } = parseBeaconLines(rawText);
const beaconLink = beaconUrl(rawText) || postUrl;
const shout = cleanBeaconText(rawText);
const thumb = embed?.$type === 'app.bsky.embed.images#view'
? embed.images[0]?.thumb
: embed?.media?.$type === 'app.bsky.embed.images#view'
? embed.media.images[0]?.thumb
: null;
contentHTML =
`<div class="bsky-feed-content">` +
(name ? `<p class="bsky-feed-beacon-header">📍 ${esc(name)}${rating ? ' <span class="bsky-feed-beacon-stars">' + stars(rating) + '</span>' : ''}</p>` : '') +
(shout ? `<p>${esc(shout)}</p>` : '') +
`</div>`;
embedHTML =
`<a class="bsky-feed-card" href="${esc(beaconLink)}" target="_blank" rel="noopener noreferrer">` +
(thumb ? `<img class="bsky-feed-card-thumb" src="${esc(thumb)}" alt="" loading="lazy" decoding="async">` : '') +
`<div class="bsky-feed-card-text">` +
(meta ? `<span class="bsky-feed-card-title">${esc(meta)}</span>` : '') +
(weather ? `<span class="bsky-feed-card-desc">${esc(weather)}</span>` : '') +
`</div></a>`;
} else {
contentHTML = `<div class="bsky-feed-content">${applyFacets(cleanText(rawText, embed), post.record.facets)}</div>`;
if (embed?.$type === 'app.bsky.embed.images#view') {
const imgs = embed.images;
if (imgs.length === 1) {
embedHTML = `<a class="bsky-feed-img-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${imgs[0].fullsize}" alt="${esc(imgs[0].alt || '')}" loading="lazy" decoding="async"></a>`;
} else {
embedHTML = `<div class="bsky-feed-img-grid">${imgs.map(img => `<a class="bsky-feed-img-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${img.thumb}" alt="${esc(img.alt || '')}" loading="lazy" decoding="async"></a>`).join('')}</div>`;
}
} else if (embed?.$type === 'app.bsky.embed.video#view') {
if (embed.thumbnail) {
embedHTML = `<a class="bsky-feed-video-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${embed.thumbnail}" alt="" loading="lazy" decoding="async"><span class="bsky-feed-video-play" aria-hidden="true">▶</span></a>`;
}
} else if (embed?.$type === 'app.bsky.embed.external#view') {
const ext = embed.external;
embedHTML =
`<a class="bsky-feed-card" href="${esc(ext.uri)}" target="_blank" rel="noopener noreferrer">` +
(ext.thumb ? `<img class="bsky-feed-card-thumb" src="${esc(ext.thumb)}" alt="" loading="lazy" decoding="async">` : '') +
`<div class="bsky-feed-card-text">` +
`<span class="bsky-feed-card-title">${esc(ext.title || '')}</span>` +
(ext.description ? `<span class="bsky-feed-card-desc">${esc(ext.description)}</span>` : '') +
`</div></a>`;
} else if (embed?.$type === 'app.bsky.embed.record#view') {
const rec = embed.record;
if (rec?.$type === 'app.bsky.embed.record#viewRecord') {
const qAuthor = rec.author?.handle || '';
const qText = rec.value?.text || '';
const qRkey = rec.uri?.split('/').pop();
const qUrl = `https://bsky.app/profile/${esc(qAuthor)}/post/${esc(qRkey)}`;
embedHTML =
`<a class="bsky-feed-quote" href="${qUrl}" target="_blank" rel="noopener noreferrer">` +
`<span class="bsky-feed-quote-author">@${esc(qAuthor)}</span>` +
`<div class="bsky-feed-quote-text">${esc(qText)}</div>` +
`</a>`;
}
} else if (embed?.$type === 'app.bsky.embed.recordWithMedia#view') {
const media = embed.media;
if (media?.$type === 'app.bsky.embed.images#view') {
const imgs = media.images;
if (imgs.length === 1) {
embedHTML = `<a class="bsky-feed-img-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${imgs[0].fullsize}" alt="${esc(imgs[0].alt || '')}" loading="lazy" decoding="async"></a>`;
} else {
embedHTML = `<div class="bsky-feed-img-grid">${imgs.map(img => `<a class="bsky-feed-img-link" href="${postUrl}" target="_blank" rel="noopener noreferrer"><img class="bsky-feed-img" src="${img.thumb}" alt="${esc(img.alt || '')}" loading="lazy" decoding="async"></a>`).join('')}</div>`;
}
}
const rec = embed.record?.record;
if (rec?.$type === 'app.bsky.embed.record#viewRecord') {
const qAuthor = rec.author?.handle || '';
const qText = rec.value?.text || '';
const qRkey = rec.uri?.split('/').pop();
const qUrl = `https://bsky.app/profile/${esc(qAuthor)}/post/${esc(qRkey)}`;
embedHTML +=
`<a class="bsky-feed-quote" href="${qUrl}" target="_blank" rel="noopener noreferrer">` +
`<span class="bsky-feed-quote-author">@${esc(qAuthor)}</span>` +
`<div class="bsky-feed-quote-text">${esc(qText)}</div>` +
`</a>`;
}
}
}
const note = document.createElement('article');
note.className = 'bsky-feed-note';
note.innerHTML =
contentHTML +
(embedHTML ? `<div class="bsky-feed-embed">${embedHTML}</div>` : '') +
`<div class="bsky-feed-meta">` +
`<a class="bsky-feed-date" href="${postUrl}" target="_blank" rel="noopener noreferrer">${when}</a>` +
`</div>`;
list.appendChild(note);
});
root.appendChild(list);
root.removeAttribute('hidden');
} catch {}
})();
</script>
Styles
/* Bluesky feed | robertbirming.com */
.bsky-feed-list {
display: flex;
flex-direction: column;
gap: calc(var(--space-block) * 0.75);
margin-block: var(--space-block);
}
.bsky-feed-note {
padding-block: 1.1rem;
padding-inline: 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.bsky-feed-content > :first-child { margin-block-start: 0; }
.bsky-feed-content > :last-child { margin-block-end: 0; }
.bsky-feed-content p {
margin-block: 0.4em;
}
.bsky-feed-beacon-stars {
color: var(--text);
}
.bsky-feed-embed {
margin-block: 1.2rem;
}
.bsky-feed-img-link {
display: block;
}
.bsky-feed-img {
width: 100%;
border-radius: var(--radius);
}
.bsky-feed-img-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.3em;
}
.bsky-feed-img-grid .bsky-feed-img {
height: 12rem;
object-fit: cover;
}
.bsky-feed-video-link {
display: block;
position: relative;
}
.bsky-feed-video-play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #fff;
background: color-mix(in srgb, var(--text) 20%, transparent);
border-radius: var(--radius);
pointer-events: none;
}
.bsky-feed-card {
display: flex;
gap: 0.6em;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
text-decoration: none;
background: transparent;
}
.bsky-feed-card-thumb {
width: 5rem;
height: 5rem;
object-fit: cover;
flex-shrink: 0;
margin: 0;
align-self: center;
}
.bsky-feed-card-text {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.2em;
padding: 0.5em 0.6em;
min-width: 0;
}
.bsky-feed-card-title {
font-size: var(--font-small);
font-weight: 600;
color: var(--text);
}
.bsky-feed-card-desc {
font-size: calc(var(--font-small) * 0.9);
color: var(--muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bsky-feed-quote {
display: block;
margin-block-start: 0.6em;
padding: 0.6em 0.8em;
border: 1px solid var(--border);
border-radius: var(--radius);
text-decoration: none;
background: transparent;
}
.bsky-feed-quote-author {
display: block;
font-size: calc(var(--font-small) * 0.9);
font-weight: 600;
color: var(--muted);
margin-block-end: 0.3em;
}
.bsky-feed-quote-text {
font-size: var(--font-small);
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bsky-feed-meta {
margin-block-start: 1.2em;
font-size: var(--font-small);
color: var(--muted);
}
.bsky-feed-date {
color: inherit;
text-decoration: none;
}
.bsky-feed-date:visited {
color: inherit;
}
@media (hover: hover) {
.bsky-feed-card:hover,
.bsky-feed-quote:hover {
background: color-mix(in srgb, var(--border) 30%, transparent);
text-decoration: none;
}
}
Happy blogging, and status posting.
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.↩