<bsky-conversation>#
A zero-dependency web component that displays a Bluesky conversation thread — replies, quote posts, and reposts — for any public Bluesky post. Drop it into any page with a single <script> tag.
Quick start#
<script type="module" src="https://unpkg.com/bsky-conversation"></script>
<bsky-conversation uri="https://bsky.app/profile/did:plc:.../post/..."></bsky-conversation>
That's it. No build step, no dependencies.
Install via npm#
npm install bsky-conversation
// Auto-registers the <bsky-conversation> element
import 'bsky-conversation'
// Or import the class for manual registration
import { BskyConversation } from 'bsky-conversation'
customElements.define('my-conversation', BskyConversation)
Attributes#
| Attribute | Default | Description |
|---|---|---|
uri |
(required) | The bsky.app post URL. Use DID-based URLs for reliability. |
max-depth |
3 |
How many levels of nested replies to show. At the cutoff, a "More of the conversation on Bluesky" link appears. |
show-original-post |
false |
Set to "true" to include the root post in the timeline. |
engage-text |
"Add your thoughts on Bluesky" |
CTA link text shown in the header and at the bottom. Set to "" to hide. |
header-template |
(none) | Custom header template string. Overrides the default header format. |
Template syntax#
The header-template attribute supports a mini template language.
Simple tokens — replaced with their value:
| Token | Value |
|---|---|
{replies} |
Raw reply count |
{quotes} |
Raw quote count |
{reposts} |
Raw repost count |
{repostedBy} |
Linked names, e.g. @alice, @bob, and 3 others |
{postUrl} |
The bsky.app post URL |
Pluralization — {name|singular|plural} outputs nothing when 0, "1 singular" when 1, "N plural" when 2+:
{replies|reply|replies} → "" or "1 reply" or "17 replies"
{quotes|quote|quotes} → "" or "1 quote" or "5 quotes"
Conditional blocks — {name?content} renders content only if the value is truthy:
{repostedBy?Reposted by {repostedBy}.} → "" or "Reposted by @alice, @bob."
{replies?{replies|reply|replies} so far} → "" or "17 replies so far"
Full example:
<bsky-conversation
uri="https://bsky.app/profile/did:plc:.../post/..."
header-template="This post has {replies?{replies|reply|replies}}{quotes?, {quotes|quote|quotes}}{repostedBy?, and has been reposted by {repostedBy}}."
/>
CSS custom properties#
The component defines design tokens with sensible defaults, overridable from the host page. All internal sizing uses em units, so it scales with inherited font size.
| Property | Light default | Dark default | Controls |
|---|---|---|---|
--bsky-border-color |
#e5e7eb |
#374151 |
Separators, thread lines |
--bsky-muted-color |
#6b7280 |
#9ca3af |
Handles, timestamps, secondary text |
--bsky-link-color |
black |
#60a5fa |
Link text color |
--bsky-link-hover |
#2563eb |
#3b82f6 |
Link hover color |
--bsky-link-underline |
rgba(82,82,91,0.5) |
rgba(59,130,246,0.3) |
Link underline color |
--bsky-link-underline-hover |
rgba(59,130,246,0.3) |
rgba(59,130,246,0.3) |
Link underline hover color |
Override example:
bsky-conversation {
--bsky-link-color: #333;
--bsky-muted-color: #888;
}
Dark mode#
The component ships with a built-in dark palette that activates when any ancestor has the dark class. This works with common dark mode patterns:
<!-- Toggle dark class on <html> or <body> -->
<html class="dark">
<body>
<bsky-conversation uri="..."></bsky-conversation>
</body>
</html>
If your site uses a different convention (e.g., data-theme="dark" or prefers-color-scheme), you can set the properties yourself:
@media (prefers-color-scheme: dark) {
bsky-conversation {
--bsky-border-color: #374151;
--bsky-muted-color: #9ca3af;
--bsky-link-color: #60a5fa;
--bsky-link-underline: rgba(59, 130, 246, 0.3);
--bsky-link-hover: #3b82f6;
--bsky-link-underline-hover: rgba(59, 130, 246, 0.3);
}
}
Behavior#
- Fetches from the public Bluesky API (no authentication needed)
- Rich text rendering with proper UTF-8 byte-offset facet handling (links, @mentions, #hashtags)
- Root post author's direct replies are filtered out (they're extensions of the original post, not conversation)
- Hidden replies (via threadgate) and detached quotes (via postgate) are filtered out
- Reply threads stay grouped — nested replies are not flattened into the timeline
- Quote posts are interleaved chronologically with top-level reply threads
- Reposts appear only in the header summary, not as timeline items
- API failures degrade gracefully — the rest of the conversation still renders
- All user content is XSS-hardened through
escapeHtml()
Moderation#
The hide-reply script lets you hide replies or detach quote posts from conversations via the command line. It auto-detects the post type.
Setup#
cp .env.example .env
Fill in your ATPROTO_HANDLE and ATPROTO_APP_PASSWORD (create an app password in Bluesky Settings > App Passwords). Then install the dev dependencies:
npm install
Usage#
# Hide a reply (adds to threadgate hiddenReplies)
npm run hide-reply https://bsky.app/profile/did:plc:.../post/...
# Detach a quote post (adds to postgate detachedEmbeddingUris)
npm run hide-reply https://bsky.app/profile/did:plc:.../post/...
The script auto-detects whether the URL is a reply or a quote post:
- Replies: Walks up the thread to find the root post and adds the reply URI to the root post's
app.bsky.feed.threadgaterecord. Equivalent to "Hide reply for everyone" on bsky.app. - Quote posts: Detects the embedded post and adds the quote URI to the root post's
app.bsky.feed.postgaterecord. Equivalent to "Detach quote" on bsky.app.
The authenticated user must own the root post being replied to or quoted.
License#
MIT