Skip to content
weftGitHub

RichTextEditor

Formatted text with a toolbar, Markdown shortcuts and / for blocks.

SourcePackage @weft-js/warp

Preview

tsx
import { RichTextEditor } from '@weft-js/warp'export function Basic() {  return (    <RichTextEditor label="Release notes" defaultValue={NOTES} mentions={people} />  )}const PEOPLE = [  { id: 'mara', name: 'Mara Lindqvist', email: 'mara@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' },  { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' },  { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' },  { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' },  { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' },  { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' },  { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' },  { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.dev', role: 'Viewer', team: 'Mobile', initials: 'IV' },]const MEMBERS = PEOPLE.map(p => ({ id: p.id, label: p.name, meta: p.team }))const matching = (list: typeof MEMBERS, q: string) => list.filter(i => i.label.toLowerCase().includes(q.toLowerCase())).slice(0, 5)const NOTES = '<h2>October release</h2><p>Product images now come from the <strong>edge</strong>, about 40% faster.</p><ul><li>Apple Pay on the last checkout step</li><li>A shorter stale window</li></ul>'const people = { search: (q: string) => Promise.resolve(matching(MEMBERS, q)) }

Installation

npm install @weft-js/warp

Usage

tsx
import { RichTextEditor } from '@weft-js/warp'<RichTextEditor label="Release notes" defaultValue={NOTES} mentions={people} />

A bubble toolbar

toolbar="bubble" shows it over a selection.

tsx
import { RichTextEditor } from '@weft-js/warp'export function Bubble() {  return (    <RichTextEditor label="Release notes" toolbar="bubble" defaultValue="<p>Select some of this text to see the toolbar.</p>" />  )}

Fewer tools

marks and blocks choose the buttons.

tsx
import { RichTextEditor } from '@weft-js/warp'export function Limited() {  return (    <RichTextEditor label="Comment" marks={['bold', 'italic', 'link']} blocks={['ul']} placeholder="Leave a comment" />  )}

Store Markdown

format="markdown" reads and writes Markdown. People still see formatted text.

tsx
import { state } from '@weft-js/core'import { CodeBlock, RichTextEditor, Stack } from '@weft-js/warp'export function Markdown() {  const text = state(NOTES_MD)  return (    <Stack gap="related">      <RichTextEditor label="Release notes" format="markdown" value={text} />      <CodeBlock code={text.value} language="markdown" />    </Stack>  )}const NOTES_MD = '## October release\n\nProduct images now come from the **edge**.\n\n- Apple Pay at checkout\n- A shorter stale window\n'

States

A rich text editor can start empty or filled, and can show a hint or an error.

tsx
import { Grid, RichTextEditor, Stack, Text } from '@weft-js/warp'export function States() {  return (    <Grid cols={3} gap="group">      <Cell name="Empty"><RichTextEditor label="Notes" toolbar="none" /></Cell>      <Cell name="Filled"><RichTextEditor label="Notes" toolbar="none" defaultValue="<p>Shipped on <strong>Friday</strong>.</p>" /></Cell>      <Cell name="Hint"><RichTextEditor label="Notes" toolbar="none" hint="Type / for blocks, @ to tag someone." mentions={people} /></Cell>      <Cell name="Invalid"><RichTextEditor label="Notes" toolbar="none" error="Write something before you publish." /></Cell>    </Grid>  )}const PEOPLE = [  { id: 'mara', name: 'Mara Lindqvist', email: 'mara@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' },  { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' },  { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' },  { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' },  { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' },  { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' },  { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' },  { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.dev', role: 'Viewer', team: 'Mobile', initials: 'IV' },]const Cell = (p: { name: string; children?: unknown }) => (  <Stack gap="tight">    <Text role="caption" subtle>{p.name}</Text>    {p.children}  </Stack>)const MEMBERS = PEOPLE.map(p => ({ id: p.id, label: p.name, meta: p.team }))const matching = (list: typeof MEMBERS, q: string) => list.filter(i => i.label.toLowerCase().includes(q.toLowerCase())).slice(0, 5)const people = { search: (q: string) => Promise.resolve(matching(MEMBERS, q)) }

When to use

Use it for

  • Long notes, articles, descriptions with structure

Reach for something else

Accessibility

⌘B
Bold
⌘I
Italic
⌘K
Link
/
Opens the block list on an empty line
←→
Moves along the toolbar
  • The toolbar is one Tab stop, with arrow keys inside it.
  • Everything that enters the editor, a value or a paste, is cleaned first.

API Reference

PropTypeDefaultDescription
labelstring—The visible name, and what screen readers announce.
hintunknown—A short line of help under the field.
errorunknown—Replaces the hint and marks the field invalid.
iconIconRef—Leading icon: it sweeps with the border.
prefixunknown—Text or an element inside the field before the value, such as $.
suffixunknown—Text or an element inside the field after the value, such as kg.
variant"floating" | "classic"—floating (default, or the app's warp({ fields })) · classic: the label above.
appearance"line"—line: only a bottom line under the value, no box or fill. The boxed field otherwise.
size"sm" | "md" | "lg"'md'Control size. md by default.
clearablebooleanfalseA clear button (shown once there is a value).
counternumber—Maximum length, shown as a counter.
idstring—The element id. One is made for you if you leave it out.
disabledbooleanfalseCan't be focused or changed.
readonlybooleanfalseShows the value and lets it be selected and copied, but not changed.
valuestring | State<string>—The current value, when you own the state.
defaultValuestring—The starting value, when the component owns the state.
format"html" | "markdown"'html'html or markdown, for what value holds.
toolbar"none" | "fixed" | "bubble"'fixed'fixed, bubble or none. A phone docks it above the keyboard.
marksMark[]['bold', 'italic', 'strike', 'code', 'link']Which inline tools to show.
blocksBlock[]['h2', 'ul', 'ol', 'quote']Which block tools to show.
commandsSlashCommand[]SLASHWhat / offers.
mentions{ search: (q: string, signal: AbortSignal) => Promise<MentionItem[]>; }—Turns on @: { search }.
placeholderstring'Write, or type / for blocks…'Shown while it's empty.
minRowsnumber—The least height in lines.
maxRowsnumber—The most height before it scrolls.
namestring—Posts the value with a form.
EventTypeDefaultDescription
onInput((e: InputEvent & { currentTarget: HTMLInputElement & HTMLTextAreaElemen…—Called on each keystroke.
onValueChange((v: string, reason: ChangeReason) => void)—Called with each new value, and whether a pointer, a key or code made the change.

Parts

Every element carries a stable name, so you can style it from your own CSS: [data-scope="rte"] [data-part="tool"]

toolframeseplinkbarsr-onlycontentcaret-anchorsweeplistboxopt-meta

RelatedMarkdown editorMentions inputTextarea