
Security News
Axios Supply Chain Attack Reaches OpenAI macOS Signing Pipeline, Forces Certificate Rotation
OpenAI rotated macOS signing certificates after a malicious Axios package reached its CI pipeline in a broader software supply chain attack.
bsky-richtext-react
Advanced tools
React components for rendering and editing Bluesky richtext content (app.bsky.richtext lexicon)
React components for rendering and editing Bluesky richtext content — the
app.bsky.richtext.facetAT Protocol lexicon.
<RichTextDisplay> — Render AT Protocol richtext records (text + facets) as interactive HTML. Handles @mentions, links, and #hashtags with fully customisable renderers and URL resolvers.<RichTextEditor> — TipTap-based editor with real-time @mention autocomplete (powered by the Bluesky public API by default — no auth required), stateless URL decoration, undo/redo, and an imperative ref API.generateClassNames() — Deep-merge utility for the classNames prop system. Pass an array of partial classNames objects and get one merged result, optionally using your own cn() / clsx / tailwind-merge utility.classNames prop — no stylesheet import needed.tsup.# bun (recommended)
bun add bsky-richtext-react
# npm
npm install bsky-richtext-react
# pnpm
pnpm add bsky-richtext-react
Peer dependencies (if not already installed):
bun add react react-dom @floating-ui/dom \
@tiptap/core@^3.20.0 \
@tiptap/extension-document@^3.20.0 \
@tiptap/extension-hard-break@^3.20.0 \
@tiptap/extension-history@^3.20.0 \
@tiptap/extension-mention@^3.20.0 \
@tiptap/extension-paragraph@^3.20.0 \
@tiptap/extension-placeholder@^3.20.0 \
@tiptap/extension-text@^3.20.0 \
@tiptap/react@^3.20.0
import { RichTextDisplay } from 'bsky-richtext-react'
// Pass raw fields from an app.bsky.feed.post record
export function Post({ post }) {
return <RichTextDisplay value={{ text: post.text, facets: post.facets }} />
}
import { RichTextEditor } from 'bsky-richtext-react'
export function Composer() {
return (
<RichTextEditor
placeholder="What's on your mind?"
onChange={(record) => {
// record.text — plain UTF-8 text
// record.facets — mentions (as handles), links, hashtags
console.log(record)
}}
/>
)
}
The editor uses the Bluesky public API for @mention search by default. Type
@followed by a handle to see live suggestions — no API key or authentication required. See Mention Search to customise or disable this.
The library ships no CSS file. All default styles are Tailwind utility classes applied through the classNames prop system. As long as Tailwind is configured in your project, components look good with zero extra setup.
Every component has a set of default Tailwind classes applied out of the box (see defaultEditorClassNames, defaultDisplayClassNames, defaultSuggestionClassNames). No stylesheet import is required.
generateClassNames() for targeted overridesThe classNames prop on each component accepts a nested object. Use generateClassNames() to cleanly layer your classes on top of the defaults without rewriting them from scratch:
import {
RichTextEditor,
generateClassNames,
defaultEditorClassNames,
} from 'bsky-richtext-react'
// Works with any class utility — clsx, tailwind-merge, your own cn()
import { cn } from '@/lib/utils'
<RichTextEditor
classNames={generateClassNames([
defaultEditorClassNames,
{
root: 'border rounded-lg p-3 focus-within:ring-2',
mention: 'text-blue-500 font-semibold',
suggestion: {
item: 'px-3 py-2 rounded-md',
itemSelected: 'bg-blue-50',
},
},
], cn)}
/>
generateClassNames() accepts any number of partial classNames objects in the array. Entries are merged left-to-right; strings at the same key are combined using cn(). Falsy entries are skipped, so conditional overrides work naturally:
classNames={generateClassNames([
defaultEditorClassNames,
isCompact && { root: 'text-sm p-2' },
isDark && darkThemeClassNames,
], cn)}
Pass a plain object to skip the defaults entirely:
<RichTextDisplay classNames={{ root: 'my-text', mention: 'my-mention' }} />
tailwind-merge to deduplicate classesWhen layering your own Tailwind classes on top of the defaults, use tailwind-merge as the cn argument to avoid conflicting class duplication:
import { twMerge } from 'tailwind-merge'
import { clsx } from 'clsx'
const cn = (...inputs) => twMerge(clsx(inputs))
<RichTextEditor
classNames={generateClassNames([
defaultEditorClassNames,
{ root: 'rounded-xl border border-gray-200 p-4' },
], cn)}
/>
<RichTextDisplay>import { RichTextDisplay } from 'bsky-richtext-react'
<RichTextDisplay value={post} />
| Prop | Type | Default | Description |
|---|---|---|---|
value | RichTextRecord | string | — | The richtext to render |
classNames | Partial<DisplayClassNames> | defaults | CSS class names for styling (use generateClassNames()) |
renderMention | (props: MentionProps) => ReactNode | <a> to bsky.app | Custom @mention renderer |
renderLink | (props: LinkProps) => ReactNode | <a> with short URL | Custom link renderer |
renderTag | (props: TagProps) => ReactNode | <a> to bsky.app | Custom #hashtag renderer |
mentionUrl | (did: string) => string | https://bsky.app/profile/${did} | Generate @mention href |
tagUrl | (tag: string) => string | https://bsky.app/hashtag/${tag} | Generate #hashtag href |
linkUrl | (uri: string) => string | identity | Transform link href (e.g. proxy URLs) |
disableLinks | boolean | false | Render all facets as plain text |
linkProps | AnchorHTMLAttributes | — | Forwarded to every default <a> |
...spanProps | HTMLAttributes<HTMLSpanElement> | — | Forwarded to root <span> |
// Point mentions and hashtags to your own app routes
<RichTextDisplay
value={post}
mentionUrl={(did) => `/profile/${did}`}
tagUrl={(tag) => `/search?tag=${encodeURIComponent(tag)}`}
/>
import { Link } from 'react-router-dom'
<RichTextDisplay
value={post}
renderMention={({ text, did }) => (
<Link to={`/profile/${did}`} className="mention">{text}</Link>
)}
/>
<RichTextEditor>import { RichTextEditor } from 'bsky-richtext-react'
<RichTextEditor
placeholder="What's on your mind?"
onChange={(record) => setPost(record)}
/>
| Prop | Type | Default | Description |
|---|---|---|---|
initialValue | RichTextRecord | string | — | Initial content (uncontrolled) |
onChange | (record: RichTextRecord) => void | — | Called on every content change |
placeholder | string | — | Placeholder text when empty |
onFocus | () => void | — | Called when editor gains focus |
onBlur | () => void | — | Called when editor loses focus |
classNames | Partial<EditorClassNames> | defaults | CSS class names for styling (use generateClassNames()) |
onMentionQuery | (query: string) => Promise<MentionSuggestion[]> | Bluesky public API | Custom mention search. Overrides the built-in search. |
mentionSearchDebounceMs | number | 300 | Debounce delay (ms) for the built-in search. No effect when onMentionQuery is set. |
disableDefaultMentionSearch | boolean | false | Disable the built-in Bluesky API search entirely |
renderMentionSuggestion | SuggestionOptions['render'] | @floating-ui/dom popup | Custom TipTap suggestion renderer factory |
mentionSuggestionOptions | DefaultSuggestionRendererOptions | — | Options forwarded to the default renderer |
editorRef | Ref<RichTextEditorRef> | — | Imperative ref |
editable | boolean | true | Toggle read-only mode |
...divProps | HTMLAttributes<HTMLDivElement> | — | Forwarded to root <div> |
RichTextEditorRefinterface RichTextEditorRef {
focus(): void
blur(): void
clear(): void
getText(): string
}
const editorRef = useRef<RichTextEditorRef>(null)
editorRef.current?.focus()
editorRef.current?.clear()
const text = editorRef.current?.getText()
The editor searches Bluesky actors by default when the user types @:
// Built-in: uses https://public.api.bsky.app, debounced 300ms
<RichTextEditor placeholder="Type @ to search…" />
// Custom debounce
<RichTextEditor mentionSearchDebounceMs={500} />
// Your own search (e.g. from an authenticated agent)
<RichTextEditor
onMentionQuery={async (query) => {
const res = await agent.searchActors({ term: query, limit: 8 })
return res.data.actors.map((a) => ({
did: a.did,
handle: a.handle,
displayName: a.displayName,
avatarUrl: a.avatar,
}))
}}
/>
// Disable built-in search (no suggestions unless you set onMentionQuery)
<RichTextEditor disableDefaultMentionSearch />
<MentionSuggestionList>The default suggestion dropdown rendered inside the @floating-ui/dom popup. Exported so you can reuse or reference it in your own popup implementation.
import { MentionSuggestionList } from 'bsky-richtext-react'
| Prop | Type | Default | Description |
|---|---|---|---|
items | MentionSuggestion[] | — | Suggestion items (from TipTap) |
command | SuggestionCommand | — | TipTap command to insert selected item |
classNames | Partial<SuggestionClassNames> | defaults | CSS class names for styling |
showAvatars | boolean | true | Show / hide avatar images |
noResultsText | string | "No results" | Empty-state message |
useRichText(record)Low-level hook. Parses a RichTextRecord into an array of typed segments.
import { useRichText } from 'bsky-richtext-react'
const segments = useRichText({ text: post.text, facets: post.facets })
// => [{ text: 'Hello ', feature: undefined }, { text: '@alice', feature: MentionFeature }, ...]
interface RichTextSegment {
text: string
feature?: MentionFeature | LinkFeature | TagFeature
}
generateClassNames(classNamesArray, cn?)Deep-merge an array of partial classNames objects into one. String values at the same key are combined (space-joined or via cn()). Nested objects are merged recursively. Falsy entries are skipped.
import {
generateClassNames,
defaultEditorClassNames,
defaultDisplayClassNames,
defaultSuggestionClassNames,
} from 'bsky-richtext-react'
// Merge defaults with overrides
const classNames = generateClassNames([
defaultEditorClassNames,
{ root: 'my-editor', mention: 'text-blue-500' },
])
// Deep nested override
const classNames = generateClassNames([
defaultEditorClassNames,
{ suggestion: { item: 'px-3 py-2', itemSelected: 'bg-blue-50' } },
])
// Conditional entries (falsy values are ignored)
const classNames = generateClassNames([
defaultEditorClassNames,
isCompact && { root: 'text-sm' },
isDark && darkThemeClassNames,
])
// With a class utility for deduplication
import { cn } from '@/lib/utils'
const classNames = generateClassNames([defaultEditorClassNames, overrides], cn)
Signature:
function generateClassNames<T extends object>(
classNamesArray: (Partial<T> | undefined | null | false)[],
cn?: (...inputs: (string | undefined | null | false)[]) => string,
): T
searchBskyActors(query, limit?)Fetch actor suggestions from the Bluesky public API. No authentication required.
import { searchBskyActors } from 'bsky-richtext-react'
const suggestions = await searchBskyActors('alice', 8)
// => [{ did, handle, displayName?, avatarUrl? }, ...]
Returns [] on empty query, network error, or non-OK response.
createDebouncedSearch(delayMs?)Create a debounced wrapper around searchBskyActors. Rapid calls within the window are coalesced — only the last query fires a network request, but all pending callers receive the result.
import { createDebouncedSearch } from 'bsky-richtext-react'
const debouncedSearch = createDebouncedSearch(400)
// Use as onMentionQuery
<RichTextEditor onMentionQuery={debouncedSearch} />
import { toShortUrl, isValidUrl, parseRichText } from 'bsky-richtext-react'
toShortUrl('https://example.com/some/long/path?q=1')
// => 'example.com/some/long/path?q=1'
isValidUrl('not a url') // => false
parseRichText({ text, facets }) // => RichTextSegment[]
All AT Protocol facet types are exported:
import type {
RichTextRecord, // { text: string; facets?: Facet[] }
Facet, // { index: ByteSlice; features: FacetFeature[] }
ByteSlice, // { byteStart: number; byteEnd: number }
MentionFeature, // { $type: 'app.bsky.richtext.facet#mention'; did: string }
LinkFeature, // { $type: 'app.bsky.richtext.facet#link'; uri: string }
TagFeature, // { $type: 'app.bsky.richtext.facet#tag'; tag: string }
FacetFeature, // MentionFeature | LinkFeature | TagFeature
RichTextSegment, // { text: string; feature?: FacetFeature }
MentionSuggestion, // { did, handle, displayName?, avatarUrl? }
RichTextEditorRef, // { focus, blur, clear, getText }
} from 'bsky-richtext-react'
ClassNames types:
import type {
DisplayClassNames, // { root?, mention?, link?, tag? }
EditorClassNames, // { root?, content?, mention?, link?, suggestion?, ... }
SuggestionClassNames, // { root?, item?, itemSelected?, avatar?, name?, handle?, ... }
ClassNameFn, // (...inputs) => string — compatible with clsx/tailwind-merge
} from 'bsky-richtext-react'
Type guards:
import { isMentionFeature, isLinkFeature, isTagFeature } from 'bsky-richtext-react'
for (const { feature } of segments) {
if (isMentionFeature(feature)) { /* feature.did */ }
if (isLinkFeature(feature)) { /* feature.uri */ }
if (isTagFeature(feature)) { /* feature.tag */ }
}
Default classNames objects (starting points for generateClassNames()):
import {
defaultDisplayClassNames,
defaultEditorClassNames,
defaultSuggestionClassNames,
} from 'bsky-richtext-react'
Richtext in AT Protocol is represented as a plain UTF-8 string (text) paired with an array of facets. Each facet maps a byte range (not a character range!) of the text to a semantic feature:
| Feature | $type | Description |
|---|---|---|
| Mention | app.bsky.richtext.facet#mention | Reference to another account (DID) |
| Link | app.bsky.richtext.facet#link | Hyperlink (full URI) |
| Tag | app.bsky.richtext.facet#tag | Hashtag (without #) |
⚠️ Byte offsets are UTF-8, but JavaScript strings are UTF-16. This library handles the conversion automatically via the
sliceByByteOffsetutility.
Full lexicon: lexicons/app/richtext/facet.json
AT Protocol docs: atproto.com/lexicons/app-bsky-richtext
# Start Storybook (component playground)
bun run dev
# Build the library
bun run build
# Run tests
bun run test
# Type-check
bun run typecheck
# Lint
bun run lint
# Format
bun run format
v2.0.0 upgrades tiptap from v2 to v3. If you are upgrading from v1.x, you must:
@tiptap/* peer dependencies to ^3.20.0.tippy.js with @floating-ui/dom.- bun add @tiptap/core@^2 @tiptap/react@^2 tippy.js
+ bun add @tiptap/core@^3.20.0 @tiptap/react@^3.20.0 @floating-ui/dom
See CHANGELOG.md for the full list of changes.
See CHANGELOG.md.
MIT © 2026 satyam-mishra-pce
FAQs
React components for rendering and editing Bluesky richtext content (app.bsky.richtext lexicon)
The npm package bsky-richtext-react receives a total of 1 weekly downloads. As such, bsky-richtext-react popularity was classified as not popular.
We found that bsky-richtext-react demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
OpenAI rotated macOS signing certificates after a malicious Axios package reached its CI pipeline in a broader software supply chain attack.

Security News
Open source is under attack because of how much value it creates. It has been the foundation of every major software innovation for the last three decades. This is not the time to walk away from it.

Security News
Socket CEO Feross Aboukhadijeh breaks down how North Korea hijacked Axios and what it means for the future of software supply chain security.