bsky-richtext-react
v2.0.0
Published
React components for rendering and editing Bluesky richtext content (app.bsky.richtext lexicon)
Maintainers
Readme
bsky-richtext-react
React components for rendering and editing Bluesky richtext content — the
app.bsky.richtext.facetAT Protocol lexicon.
Features
<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 theclassNamesprop system. Pass an array of partial classNames objects and get one merged result, optionally using your owncn()/clsx/tailwind-mergeutility.- Tailwind defaults, fully overridable — Default classNames use Tailwind utility classes out of the box. Override any part via the
classNamesprop — no stylesheet import needed. - Fully typed — TypeScript-first with complete type definitions for all AT Protocol facet types.
- Tree-shakeable — ESM + CJS dual build via
tsup.
Installation
# bun (recommended)
bun add bsky-richtext-react
# npm
npm install bsky-richtext-react
# pnpm
pnpm add bsky-richtext-reactPeer 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.0Quick Start
Rendering a post
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 }} />
}Composing a post
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.
Styling
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.
1. Defaults — Tailwind utility classes
Every component has a set of default Tailwind classes applied out of the box (see defaultEditorClassNames, defaultDisplayClassNames, defaultSuggestionClassNames). No stylesheet import is required.
2. Use generateClassNames() for targeted overrides
The 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)}Opting out of defaults
Pass a plain object to skip the defaults entirely:
<RichTextDisplay classNames={{ root: 'my-text', mention: 'my-mention' }} />3. Using tailwind-merge to deduplicate classes
When 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)}
/>API Reference
<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> |
Custom routing example
// Point mentions and hashtags to your own app routes
<RichTextDisplay
value={post}
mentionUrl={(did) => `/profile/${did}`}
tagUrl={(tag) => `/search?tag=${encodeURIComponent(tag)}`}
/>Custom renderer example
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> |
RichTextEditorRef
interface 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()Mention Search
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
}Utilities
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,
): TsearchBskyActors(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} />Other utilities
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[]Types
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'AT Protocol Background
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
Development
# 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 formatMigration
v2.0.0 — tiptap v3 upgrade
v2.0.0 upgrades tiptap from v2 to v3. If you are upgrading from v1.x, you must:
- Update all
@tiptap/*peer dependencies to^3.20.0. - Replace
tippy.jswith@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/domSee CHANGELOG.md for the full list of changes.
Changelog
See CHANGELOG.md.
License
MIT © 2026 satyam-mishra-pce
