npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bsky-richtext-react

v2.0.0

Published

React components for rendering and editing Bluesky richtext content (app.bsky.richtext lexicon)

Readme

bsky-richtext-react

React components for rendering and editing Bluesky richtext content — the app.bsky.richtext.facet AT Protocol lexicon.

npm version CI License: MIT Bundle Size


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 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.
  • Tailwind defaults, fully overridable — Default classNames use Tailwind utility classes out of the box. Override any part via the classNames prop — 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-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

Quick 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,
): 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} />

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 sliceByByteOffset utility.

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 format

Migration

v2.0.0 — tiptap v3 upgrade

v2.0.0 upgrades tiptap from v2 to v3. If you are upgrading from v1.x, you must:

  1. Update all @tiptap/* peer dependencies to ^3.20.0.
  2. Replace 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.


Changelog

See CHANGELOG.md.


License

MIT © 2026 satyam-mishra-pce