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

@tolipovjs/rich-text

v2.2.0

Published

A modern, lightweight, fully featured React rich text editor with CSS-variable theming and zero peer styling dependencies

Downloads

466

Readme

@tolipovjs/rich-text

npm version npm downloads CI bundle size types license PRs Welcome Playground

A modern, lightweight React rich text editor — no Tailwind required, fully themeable via CSS variables, with an imperative ref API and a customizable toolbar.

🎮 Try the live playground → · Open in StackBlitz

v2.0 is a breaking release. See the Migration Guide below.


Why @tolipovjs/rich-text?

| Feature | @tolipovjs/rich-text | TinyMCE | CKEditor 5 | Lexical (Meta) | TipTap | Quill | |---|---|---|---|---|---|---| | Price | 🟢 Free MIT | 🔴 $79/mo+ (paid plans) | 🔴 $99/mo+ | 🟢 Free MIT | 🟡 Pro $149/mo | 🟢 Free BSD | | Bundle (minzip) | 🟢 ~22 KB | 🔴 ~400 KB | 🔴 ~250 KB | 🟢 ~30 KB | 🟡 ~80 KB | 🟡 ~45 KB | | Notion-style slash menu | 🟢 Built-in | 🔴 Paid plugin | 🔴 Paid plugin | 🟡 DIY | 🟡 Plugin | 🔴 No | | Markdown shortcuts | 🟢 Built-in | 🔴 Paid plugin | 🟡 Plugin | 🟡 DIY | 🟢 Plugin | 🔴 No | | Bubble/floating toolbar | 🟢 Built-in | 🟢 Yes | 🟢 Yes | 🟡 DIY | 🟢 Plugin | 🔴 No | | CSS-variable theming | 🟢 Native | 🟡 Limited | 🟡 SCSS rebuild | 🟡 DIY | 🟡 DIY | 🟡 DIY | | Dark mode (built-in) | 🟢 Auto + manual | 🟡 Paid plugin | 🟡 Skin | 🟡 DIY | 🟡 DIY | 🔴 DIY | | HTML sanitizer | 🟢 Built-in | 🟢 Yes | 🟢 Yes | 🟡 DIY | 🟡 DIY | 🟡 Basic | | HTML → Markdown export | 🟢 Built-in | 🔴 Paid | 🟡 Plugin | 🔴 DIY | 🟢 Plugin | 🔴 DIY | | TypeScript types | 🟢 First-class | 🟢 Yes | 🟢 Yes | 🟢 Yes | 🟢 Yes | 🟡 @types | | SSR (Next.js) safe | 🟢 Out of box | 🟡 Workarounds | 🟡 Workarounds | 🟢 Yes | 🟢 Yes | 🟡 Workarounds | | Peer deps | 🟢 lucide-react only | 🔴 None (loads CDN) | 🔴 Heavy | 🟢 None | 🟡 ProseMirror suite | 🟢 None | | API style | 🟢 React idiomatic | 🟡 jQuery-ish | 🟡 Builder | 🟢 React | 🟢 React | 🟡 Imperative |

TL;DR: Free, lightweight, batteries-included. Notion UX without paying $99/month.


Highlights

  • Zero CSS framework lock-in — ships a single stylesheet you import once.
  • Theme via plain CSS custom properties (--rte-*).
  • Built-in light, dark, auto modes (prefers-color-scheme).
  • Notion-style UX (v2.1): slash command menu, Markdown shortcuts, floating bubble toolbar.
  • Productivity (v2.2): find & replace (Ctrl/Cmd+F), debounced autosave, Word/Google Docs paste cleanup, drag-resize images, dirty-state tracking.
  • Imperative ref API: focus(), clear(), getHTML(), setHTML(), insertHTML(), getText(), getStats().
  • Toolbar customization via presets (all / basic / minimal), built-in IDs, or custom buttons.
  • Async server image uploads via onImageUpload.
  • HTML → Markdown export (htmlToMarkdown).
  • Built-in undo/redo history stack.
  • Hardened HTML sanitizer (no style/onclick/javascript:/raw data: by default).
  • SSR safe — every document/window touch is guarded.
  • Read-only mode, max length, word/char count, task lists, subscript/superscript, indent/outdent.

Install

npm i @tolipovjs/rich-text
# or
yarn add @tolipovjs/rich-text
# or
pnpm add @tolipovjs/rich-text

Peer deps: react ^18 || ^19, react-dom ^18 || ^19. No styling library required.


Quick Start

import { useState } from "react"
import { RichTextEditor } from "@tolipovjs/rich-text"
import "@tolipovjs/rich-text/styles.css"   // ← import once in your app

export function MyEditor() {
  const [html, setHtml] = useState("<p>Hello world!</p>")
  return <RichTextEditor value={html} onChange={setHtml} />
}

That's it — no Tailwind config, no extra setup.


Theming

All visuals are driven by CSS custom properties. Override any of them in your own stylesheet:

/* Custom brand theme */
.my-editor {
  --rte-accent: #ff6b9d;
  --rte-btn-active-bg: #ff6b9d;
  --rte-bg: #fff8fb;
  --rte-radius: 12px;
}
<RichTextEditor className="my-editor" value={html} onChange={setHtml} />

Dark / Light / Auto

<RichTextEditor theme="dark" />     // forced dark
<RichTextEditor theme="light" />    // forced light
<RichTextEditor theme="auto" />     // follow OS preference

Variable reference

| Variable | Purpose | |---|---| | --rte-bg, --rte-fg | Root background / foreground | | --rte-muted-fg, --rte-placeholder-fg | Muted text / placeholder | | --rte-border, --rte-border-strong | Borders | | --rte-surface, --rte-surface-elevated | Editor / popover surfaces | | --rte-toolbar-bg, --rte-toolbar-fg, --rte-toolbar-separator | Toolbar | | --rte-btn-fg, --rte-btn-hover-bg, --rte-btn-active-bg, --rte-btn-active-fg | Toolbar buttons | | --rte-accent, --rte-accent-hover, --rte-accent-fg | Primary accent | | --rte-input-bg, --rte-input-fg, --rte-input-border, --rte-input-focus | Form inputs in popovers | | --rte-dropdown-bg, --rte-dropdown-border, --rte-dropdown-shadow | Popovers | | --rte-code-bg, --rte-code-fg | <code> / <pre> | | --rte-quote-border, --rte-quote-fg | <blockquote> | | --rte-table-border, --rte-table-header-bg | Tables | | --rte-image-outline | Image hover outline | | --rte-radius, --rte-radius-sm, --rte-radius-md | Corner radii | | --rte-font-family, --rte-font-mono | Fonts | | --rte-min-height | Editor surface min height | | --rte-toolbar-gap, --rte-toolbar-padding | Toolbar spacing |


Props

| Prop | Type | Default | Description | |---|---|---|---| | value | string | "" | Controlled HTML content | | onChange | (html: string) => void | — | Fires on sanitized content change | | placeholder | string | "Start typing..." | Empty-state text | | className | string | "" | Extra class on root | | style | React.CSSProperties | — | Inline style on root | | disabled | boolean | false | Disable + grey out | | readOnly | boolean | false | View-only (no editing) | | theme | "light" \| "dark" \| "auto" | "light" | Theme | | toolbar | "all" \| "basic" \| "minimal" \| ToolbarItem[] | "all" | Toolbar layout | | customButtons | ToolbarButtonConfig[] | — | Append custom buttons | | onImageUpload | (file: File) => Promise<string> | — | Async upload — return final URL | | autoFocus | boolean | false | Focus editor on mount | | maxLength | number | — | Hard cap on character count | | textColorPresets | string[] | 24 defaults | Override text color swatches | | backgroundColorPresets | string[] | 24 defaults | Override BG color swatches | | minHeight | string | "300px" | CSS min-height for surface | | showStats | boolean | false | Show word/char count | | allowHtmlMode | boolean | true | Allow Visual ↔ HTML toggle | | onFocus, onBlur, onSelectionChange | () => void | — | Lifecycle hooks | | slashMenu | boolean \| SlashCommand[] | false | Enable / command popup. true for defaults, or supply custom commands. | | markdownShortcuts | boolean | false | Convert **bold**, # heading, - list, > quote, `code`, ---, ``` on the fly. | | bubbleToolbar | boolean \| BubbleItem[] | false | Floating toolbar above text selection. | | findReplace | boolean | false | Enable Ctrl/Cmd+F find & replace popup. | | autosave | AutosaveConfig | — | { interval?, onSave, onError? } — debounced save when content settles. | | cleanPaste | boolean | true | Clean up pasted HTML from Word / Google Docs / Apple Pages. | | imageResize | boolean | false | Click an image to reveal drag-resize handles. | | onAutosave | (html: string) => void | — | Fires after every debounced autosave. | | onDirtyChange | (isDirty: boolean) => void | — | Fires when dirty state flips. |


Toolbar Customization

Presets

<RichTextEditor toolbar="minimal" />
<RichTextEditor toolbar="basic" />
<RichTextEditor toolbar="all" />

Build your own from built-in IDs

<RichTextEditor
  toolbar={[
    "undo", "redo", "|",
    "bold", "italic", "underline", "|",
    "heading", "|",
    "link", "image",
  ]}
/>

Built-in toolbar IDs

undo · redo · heading · paragraph · bold · italic · underline · strike · sub · sup · colorText · colorBg · alignLeft · alignCenter · alignRight · alignJustify · ul · ol · checklist · indent · outdent · quote · code · codeblock · hr · link · image · table · clear · | (separator)

Add your own button

import { Sparkles } from "lucide-react"

<RichTextEditor
  customButtons={[
    {
      id: "ai",
      title: "AI assist",
      icon: <Sparkles size={16} />,
      onClick: () => console.log("✨"),
    },
  ]}
/>

Notion-style UX (v2.1)

Opt in to slash commands, Markdown shortcuts, and a floating selection toolbar:

<RichTextEditor
  slashMenu             // type "/" to open the command palette
  markdownShortcuts     // **bold**, # heading, - list, > quote, ---
  bubbleToolbar         // floating toolbar on text selection
/>

Custom slash commands

import { DEFAULT_SLASH_COMMANDS, type SlashCommand } from "@tolipovjs/rich-text"
import { Sparkles } from "lucide-react"

const ai: SlashCommand = {
  id: "ai",
  label: "AI continue",
  description: "Let the model finish this sentence",
  icon: <Sparkles size={16} />,
  keywords: ["ai", "continue"],
  run: () => doAiStuff(),
}

<RichTextEditor slashMenu={[ai, ...DEFAULT_SLASH_COMMANDS]} />

Custom bubble toolbar items

<RichTextEditor bubbleToolbar={["bold", "italic", "code", "|", "h1", "h2", "link"]} />

Productivity (v2.2)

<RichTextEditor
  findReplace                                 // Ctrl/Cmd+F → popup
  imageResize                                 // click image → corner drag handles
  cleanPaste                                  // strip Word/Google Docs clutter (default true)
  autosave={{
    interval: 1500,                           // debounce window in ms
    onSave: (html) => fetch("/draft", { method: "PUT", body: html }),
    onError: (err) => console.error(err),
  }}
  onDirtyChange={(dirty) => setUnsavedBadge(dirty)}
/>

Dirty tracking

const ref = useRef<RichTextEditorHandle>(null)

// After successful save:
await api.save(ref.current!.getHTML())
ref.current!.markClean()

// Check from anywhere:
if (ref.current!.isDirty()) warnBeforeNavigate()

Open find & replace programmatically

ref.current?.openFindReplace()
ref.current?.closeFindReplace()

Paste cleanup

When cleanPaste is enabled (default), pasted HTML from Word, Google Docs, or Apple Pages is run through cleanPastedHtml() before being inserted. MSO conditional comments, mso-* styles, XML namespaces, empty spans and font tags are stripped. Plain pastes from the same editor or simple text are left alone.

Imperative API (ref)

import { useRef } from "react"
import { RichTextEditor, type RichTextEditorHandle } from "@tolipovjs/rich-text"

function Editor() {
  const ref = useRef<RichTextEditorHandle>(null)

  return (
    <>
      <button onClick={() => ref.current?.focus()}>Focus</button>
      <button onClick={() => ref.current?.clear()}>Clear</button>
      <button onClick={() => console.log(ref.current?.getHTML())}>Log HTML</button>
      <button onClick={() => console.log(ref.current?.getStats())}>Word count</button>
      <RichTextEditor ref={ref} />
    </>
  )
}

Methods: focus() · blur() · clear() · getHTML() · setHTML(html) · insertHTML(html) · getText() · execCommand(cmd, value?) · getStats() · isDirty() · markClean() · openFindReplace() · closeFindReplace()


Image Uploads

By default, pasted/inserted images become base64 data URLs. Replace with a server upload:

<RichTextEditor
  onImageUpload={async (file) => {
    const form = new FormData()
    form.append("image", file)
    const res = await fetch("/api/upload", { method: "POST", body: form })
    const { url } = await res.json()
    return url
  }}
/>

The returned URL is inserted as <img src="…" />.


Exports

import {
  // Components
  RichTextEditor,
  Toolbar, ToolbarButton, ColorPicker, ImageHandler, TableManager, LinkManager,

  // Utilities
  EditorCommands, HTMLSanitizer, HistoryStack, htmlToMarkdown,

  // Context
  RichTextEditorContext, useRichTextEditor,

  // Types
  type RichTextEditorProps,
  type RichTextEditorHandle,
  type ToolbarItem, type ToolbarPreset, type BuiltInToolbarItem,
  type ToolbarButtonConfig, type ColorPickerProps,
  type SanitizeOptions, type EditorStats, type Theme,
} from "@tolipovjs/rich-text"

htmlToMarkdown(html)

import { htmlToMarkdown } from "@tolipovjs/rich-text"

const md = htmlToMarkdown("<h1>Hi</h1><p>It's <strong>me</strong></p>")
// → "# Hi\n\nIt's **me**"

HTMLSanitizer.sanitize(html, opts?)

HTMLSanitizer.sanitize(dirty)                         // safe defaults
HTMLSanitizer.sanitize(dirty, { allowStyle: true })   // permit inline style

Migration from v1

| v1 | v2 | |---|---| | Required Tailwind in consumer app | Tailwind removed. Import @tolipovjs/rich-text/styles.css once. | | Hardcoded colors | All visuals via --rte-* CSS variables. | | theme / apiKey props (no-op) | theme now actually controls light/dark/auto. apiKey removed. | | No ref | Wrap with forwardRef. Use useRef<RichTextEditorHandle>(). | | All toolbar buttons always | Pass toolbar prop with preset or custom array. | | Base64 image only | Pass onImageUpload for server upload. | | style attribute allowed in sanitizer | Disabled by default. Pass { allowStyle: true } to opt back in. |


Browser Support

Chrome 60+ · Firefox 55+ · Safari 12+ · Edge 79+. The editor uses document.execCommand (deprecated but still supported in all modern browsers).


Recipes

Copy-paste ready snippets for common use cases.

📝 Blog post editor

import { useState } from "react"
import { RichTextEditor, htmlToMarkdown } from "@tolipovjs/rich-text"
import "@tolipovjs/rich-text/styles.css"

export function BlogEditor({ onPublish }: { onPublish: (md: string) => void }) {
  const [html, setHtml] = useState("")
  return (
    <>
      <RichTextEditor
        value={html}
        onChange={setHtml}
        toolbar="all"
        slashMenu
        markdownShortcuts
        bubbleToolbar
        placeholder="Write your post… Type / for blocks"
        minHeight="500px"
      />
      <button onClick={() => onPublish(htmlToMarkdown(html))}>Publish</button>
    </>
  )
}

💬 Comment box (compact)

<RichTextEditor
  value={html}
  onChange={setHtml}
  toolbar="minimal"
  bubbleToolbar
  showStats={false}
  maxLength={500}
  minHeight="80px"
  placeholder="Add a comment…"
/>

📓 Note-taking app

<RichTextEditor
  value={html}
  onChange={setHtml}
  toolbar="basic"
  slashMenu
  markdownShortcuts
  bubbleToolbar
  theme="auto"
  placeholder="Press / for blocks, ** for bold, # for heading"
/>

📧 Email composer

<RichTextEditor
  value={html}
  onChange={setHtml}
  toolbar={["bold", "italic", "underline", "|", "link", "|", "ul", "ol"]}
  allowHtmlMode={false}
  onImageUpload={async (file) => {
    const fd = new FormData()
    fd.append("file", file)
    const res = await fetch("/api/attach", { method: "POST", body: fd })
    const { url } = await res.json()
    return url
  }}
/>

🔗 react-hook-form integration

import { Controller, useForm } from "react-hook-form"
import { RichTextEditor } from "@tolipovjs/rich-text"

export function PostForm() {
  const { control, handleSubmit } = useForm({ defaultValues: { content: "" } })

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <Controller
        name="content"
        control={control}
        rules={{ required: true, minLength: 20 }}
        render={({ field }) => (
          <RichTextEditor value={field.value} onChange={field.onChange} />
        )}
      />
      <button type="submit">Save</button>
    </form>
  )
}

⚡ Next.js App Router (SSR safe)

// app/editor/page.tsx
"use client"

import dynamic from "next/dynamic"

const Editor = dynamic(
  () => import("@tolipovjs/rich-text").then((m) => m.RichTextEditor),
  { ssr: false },
)

export default function Page() {
  return <Editor placeholder="SSR-safe…" />
}

Or skip dynamic() entirely — the editor already guards every window/document touch.

☁️ Cloudinary upload

<RichTextEditor
  onImageUpload={async (file) => {
    const fd = new FormData()
    fd.append("file", file)
    fd.append("upload_preset", "your_preset")
    const res = await fetch("https://api.cloudinary.com/v1_1/<cloud>/image/upload", {
      method: "POST",
      body: fd,
    })
    return (await res.json()).secure_url
  }}
/>

Develop

npm install
npm run dev          # vite playground (examples/playground)
npm run build        # tsup → dist/ (esm + cjs + dts + styles.css)
npm run type-check   # tsc --noEmit
npm test             # vitest run

Used by

Building something with @tolipovjs/rich-text? Open a PR to add your project here.


Contributing

Repo: https://github.com/ZiyovuddinTolipov/rich-text

  1. Fork
  2. git checkout -b feature/x
  3. git commit -m "feat: x"
  4. git push origin feature/x
  5. Open a PR

Bug reports, feature requests, and PRs all welcome.


Support

If this library saves you time:


License

MIT © TolipovJS