Rich text editor
AIProA Tiptap editor with headings, lists, quotes, code, links and tables, a toolbar, a slash menu, a selection menu, and AI editing that streams a rewrite as a diff before it replaces anything.
npx prodular-ui add rich-text-editorPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Document
Toolbar formatting, a table with its own controls when the cursor is inside, and / for blocks.
"use client"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
const notes = `<h2>October close</h2><p>Revenue closed at <strong>$482,300</strong>, up 6.8% on September. Two items need a decision before we lock the books on <strong>November 3</strong>.</p><ul> <li><p>Kestrel Health is 12 days late on three invoices. Reminders bounce, so Renata is fixing the contact first.</p></li> <li><p>The Meridian payout short paid by $52.60 in wire fees. Book it to bank fees.</p></li></ul><blockquote><p>Payouts over $10,000 still need a second approver until the new policy is signed.</p></blockquote><table> <tr><th><p>Account</p></th><th><p>September</p></th><th><p>October</p></th></tr> <tr><td><p>Cash collected</p></td><td><p>$574,200</p></td><td><p>$611,940</p></td></tr> <tr><td><p>Receivables</p></td><td><p>$305,100</p></td><td><p>$318,450</p></td></tr></table><p></p>`
export default function RichTextEditorDocument() { return <RichTextEditor defaultValue={notes} aria-label="Month end notes" className="w-full max-w-2xl" minHeight={320} />}AI editing
Select the first paragraph and press Ask AI. The rewrite streams in as a diff: Replace, Discard or Try again. On an empty line, / then Ask AI to write.
"use client"
import { RichTextEditor, type AiRequest } from "@/components/ui/rich-text-editor"
const draft = `<p>hey team, so basically the payout from meridian didnt match because there was some wire fees that they took out and we need to figure out what to do with it, i think we should just book it to bank fees but not 100% sure.</p><p>Select the paragraph above and press Ask AI, or type / on an empty line and pick Ask AI to write.</p>`
const canned: Record<string, string> = { improve: "Team, the Meridian payout did not match because the bank deducted $52.60 in wire fees. I suggest we book the difference to bank fees. Let me know if you see it differently.", fix: "Hey team, so basically the payout from Meridian didn't match because there were some wire fees that they took out, and we need to figure out what to do with them. I think we should just book them to bank fees, but I'm not 100% sure.", shorter: "The Meridian payout is $52.60 short because of wire fees. I suggest booking it to bank fees.", longer: "Team, the Meridian payout did not match the invoices it covers. The bank deducted $52.60 in wire fees before the funds landed, so the deposit is short by exactly that amount. I suggest we book the difference to bank fees, as we did for the Northwind payout in August. If anyone thinks it should be charged back to Meridian instead, say so before Thursday's close.", "tone:Professional": "Team, the Meridian payout was short by $52.60 due to wire fees deducted in transit. I propose recording the difference as bank fees. Please flag any concerns before close.", "tone:Friendly": "Hi all! Quick one: the Meridian payout came in $52.60 light because of wire fees. Booking it to bank fees sounds right to me. Shout if you disagree!", "tone:Direct": "Meridian payout: $52.60 short, wire fees. Booking it to bank fees.", "translate:German": "Team, die Auszahlung von Meridian stimmte nicht, weil die Bank 52,60 $ an Überweisungsgebühren abgezogen hat. Ich schlage vor, die Differenz als Bankgebühren zu buchen.", "translate:Polish": "Zespole, wypłata od Meridian się nie zgadzała, ponieważ bank potrącił 52,60 USD opłat za przelew. Proponuję zaksięgować różnicę jako opłaty bankowe.", "translate:Spanish": "Equipo, el pago de Meridian no cuadraba porque el banco descontó 52,60 USD en comisiones de transferencia. Propongo registrar la diferencia como comisiones bancarias.", "translate:English": "Team, the Meridian payout did not match because the bank deducted $52.60 in wire fees. I suggest we book the difference to bank fees.", write: "Summary for the close: revenue is $482,300, up 6.8% on September. Two items are open. Kestrel Health is 12 days late on three invoices, and the Meridian payout is $52.60 short because of wire fees.",}
// Stands in for a route that calls the model. It streams the answer a few words at a time.async function* mockModel(request: AiRequest, signal: AbortSignal) { const key = request.option ? `${request.action}:${request.option}` : request.action const text = canned[key] ?? (request.action === "custom" ? `${canned.improve} (${request.prompt})` : canned.improve) const words = text.split(/(\s+)/) for (let i = 0; i < words.length; i += 3) { if (signal.aborted) return await new Promise((r) => setTimeout(r, 45)) yield words.slice(i, i + 3).join("") }}
export default function RichTextEditorAi() { return <RichTextEditor defaultValue={draft} ai={{ onRun: mockModel }} aria-label="Message to the finance team" className="w-full max-w-2xl" minHeight={200} />}Compact
No toolbar for comments and replies. The selection menu and / still work.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"import { RichTextEditor } from "@/components/ui/rich-text-editor"
export default function RichTextEditorComment() { const [html, setHtml] = React.useState("") const empty = !html || html === "<p></p>" return ( <div className="flex w-full max-w-lg flex-col gap-2"> <RichTextEditor value={html} onChange={setHtml} toolbar={false} minHeight={88} placeholder="Reply to Priya, or press / for blocks" aria-label="Reply" /> <div className="flex items-center justify-between"> <span className="text-[13px] text-muted-foreground">Select text for formatting.</span> <Button size="sm" disabled={empty} onClick={() => setHtml("")}> Reply </Button> </div> </div> )}Anatomy
<RichTextEditor value={html} onChange={setHtml} aria-label="Policy" ai={{ onRun: async function* (request, signal) { const res = await fetch("/api/edit", { method: "POST", body: JSON.stringify(request), signal }) const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader() for (;;) { const { value, done } = await reader.read(); if (done) return; yield value } }, }}/>Guidance
Use it when
- Long form text people format: notes, descriptions, policies, emails, comments.
- ai when the product has a model and people edit prose: rewrite, shorten, change tone, translate, or draft at the cursor.
Avoid
- Short plain fields like a title or a name. Use Input.
- Code. Use a code editor; the code block here is for snippets inside prose.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Store the HTML from onChange, and sanitize it on the server before rendering it anywhere else.
- Give it aria-label describing the document (Month end notes, Reply).
- For ai, implement onRun as an async generator that reads your model route's text stream and yields chunks. Pass the AbortSignal to fetch. With the AI SDK, return result.toTextStreamResponse() from the route.
- Never apply AI output without the review step: the editor shows a diff and only changes the document on Replace or Insert.
- Use toolbar={false} for comments and replies; keep the toolbar for documents.
API
RichTextEditor
| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue / onChange | string (HTML) | Controlled or uncontrolled content. | |
| placeholder | string | "Write, or press / for blocks" | Shown in an empty document. |
| toolbar | boolean | true | Hide the toolbar for compact uses. |
| editable | boolean | true | Read only when false. |
| minHeight | number | 180 | Minimum height of the writing area in px. |
| ai.onRun | (request, signal) => AsyncIterable<string> | Promise<string> | request has action (improve, fix, shorter, longer, tone, translate, custom, write), option, prompt, text and context. | |
| ai.tones / ai.languages | string[] | Choices under Change tone and Translate. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add rich-text-editor# or, with the registry in components.jsonnpx shadcn@latest add @prodular/rich-text-editor