<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.
See it live on the atproto.com blog or a demo.
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.
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 to let you customize how you introduce the conversation on your site.
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}}."
/>
Styling timeline items#
The component assigns distinct CSS classes to each type of timeline item, so you can style replies, threaded replies, and quote posts independently:
| Selector | What it matches |
|---|---|
.bsky-conversation .reply |
A direct reply to the original post |
.bsky-conversation .thread .reply |
A nested reply within a thread (child of another reply) |
.bsky-conversation .quote |
A quote post |
.bsky-conversation .original |
The original post (when show-original-post="true") |
.bsky-conversation .thread |
The <ol> wrapper around nested replies — has a left border by default |
For example, to visually distinguish quote posts from replies:
bsky-conversation .quote {
background: #f8f8fa;
border-radius: 8px;
padding: 1em;
}
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 directly 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
- If quote or repost API calls fail, the thread still renders without that data. If the thread itself cannot be fetched, the component renders nothing.
- All user content is XSS-hardened through
escapeHtml()
Moderation#
Sometimes people will reply or repost in a manner you don't want to appear on your site. You can use Bluesky's built in tools to help you manage this.
To hide a reply, go to the post on bsky.app, select "Hide reply from everyone" from the ellipsis menu, and it will no longer appear on the page.
If someone quotes your post and you detach that quote, it will no longer appear in the conversation.
Additionally, this package includes an optional script that takes the full URL of a post that you wish to hide. In order for this to work, you will need to create a .env file and include your bsky handle and an app password.
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