RichTextEditor
RichTextEditor
A single, lazy-loaded WYSIWYG surface backed by TipTap — which is itself a thin ergonomic layer over ProseMirror. The base @helix-ui/core bundle does not pull TipTap until a <RichTextEditor> actually mounts.
Install only what you use. Starter kit covers most documents:
pnpm add @tiptap/core @tiptap/pm @tiptap/starter-kitPer-extension peers — add the ones you actually enable:
pnpm add @tiptap/extension-underline @tiptap/extension-link \ @tiptap/extension-placeholder @tiptap/extension-text-align \ @tiptap/extension-highlight @tiptap/extension-subscript \ @tiptap/extension-superscript @tiptap/extension-task-list \ @tiptap/extension-task-item @tiptap/extension-imageBasic
import { useState } from 'react';import { RichTextEditor } from '@helix-ui/core';
const [html, setHtml] = useState('<p>Hello, <strong>helix-ui</strong>.</p>');return <RichTextEditor value={html} onChange={setHtml} />;Custom toolbar
<RichTextEditor value={html} onChange={setHtml} renderToolbar={(handle, state) => ( <Stack direction="row" gap={1}> <IconButton aria-label="bold" isActive={state.active.bold} onPress={() => handle.run('bold')}>B</IconButton> <IconButton aria-label="italic" isActive={state.active.italic} onPress={() => handle.run('italic')}><i>I</i></IconButton> <Button size="sm" onPress={() => handle.setLink(prompt('URL') ?? null)}>Link</Button> </Stack> )}/>Imperative handle
const editorRef = useRef<RichTextEditorHandle>(null);
<RichTextEditor value={html} onChange={setHtml} editorRef={editorRef} />
<Button onPress={() => editorRef.current?.run('bold')}>Bold</Button><Button onPress={() => editorRef.current?.insertImage('/logo.png', 'helix-ui')}>Insert image</Button>Read-only / preview mode
<RichTextEditor value={html} readOnly toolbar={false} />Extending TipTap directly
import Mention from '@tiptap/extension-mention';
<RichTextEditor value={html} onChange={setHtml} tiptapExtensions={[Mention.configure({ HTMLAttributes: { class: 'mention' } })]}/>Theming
theme="auto" watches document.documentElement.dataset.theme and retunes live. Heading sizes, code blocks, blockquote rules, and the toolbar all use helix-ui tokens, so the editor inherits the active DNA theme automatically.
Mobile
Below the helix-ui mobile breakpoint (≤767px):
- Toolbar gains larger touch targets (32×32 buttons).
- Surface padding tightens.
- Default font size drops to
--helix-ui-font-size-sm.
Install: @helix-ui/core
import { RichTextEditor } from '@helix-ui/core'status: stable · since: 0.7.0
Tags: wysiwyg, editor, tiptap, prosemirror
Anatomy
┌── toolbar (B I U • ¶ ) ──┐├──────────────────────────┤│ editable content area ││ … │└──────────────────────────┘Layout
- display —
block - width —
fill - height —
fill - intrinsicSize —
fills available; toolbar pinned top - stackable —
false - fullBleed —
false
Visual
A WYSIWYG editor with a default toolbar (bold, italic, lists, links, code) and a content area. Lazy-loads TipTap and selected extensions. Imperative handle exposes ProseMirror APIs.
Props
| Name | Type | Default | Description |
|---|---|---|---|
value | string | '' | HTML string for the current document. |
onChange | (html: string) => void | — | Fires on every edit with the new HTML. |
onChangeText | (text: string) => void | — | Plain-text snapshot, fired alongside onChange. |
onChangeJSON | (json: unknown) => void | — | TipTap JSON snapshot, fired alongside onChange. |
onSelectionChange | (state: RichTextSelectionState) => void | — | Fires whenever the selection or active formats change. |
extensions | RichTextExtension[] | 'all' | starter | Built-in extensions to enable. Defaults to a starter preset; pass ‘all’ to enable everything. |
theme | 'light' | 'dark' | 'auto' | auto | auto follows document.documentElement.dataset.theme. |
readOnly | boolean | false | Disable editing. |
autoFocus | boolean | false | Focus the editor when it mounts. |
placeholder | string | Write something… | Empty-state hint. Requires the placeholder extension (on by default). |
toolbar | boolean | true | Show the built-in toolbar. |
renderToolbar | (handle, state) => ReactNode | — | Render a custom toolbar instead of the default. Receives the imperative handle and current selection state. |
height | number | string | 360 | Editing surface height. Numbers are treated as px. |
editorRef | Ref<RichTextEditorHandle> | — | Imperative handle for advanced control. |
tiptapExtensions | unknown[] | [] | Extra TipTap extensions appended after the built-in ones. |
onMount | (handle: RichTextEditorHandle) => void | — | Fires once mounted, with the imperative handle. |
Tokens used
color.bg.surface.default, color.bg.surface.subtle, color.bg.surface.inverse, color.border.default, color.border.focus, color.text.primary, color.text.muted, color.text.action.brand, color.text.action.danger, color.bg.action.brand.default, color.bg.action.danger.subtle, radius.md, radius.sm, font.family.sans, font.family.mono, motion.duration.fast, motion.easing.standard
Accessibility
Notes
- TipTap exposes a
role='textbox' aria-multiline='true'surface. - Toolbar buttons set
aria-pressedfor active states. - Container shows a
:focus-withinring matching helix-ui’s other inputs. - Respects
prefers-reduced-motionvia the global motion stylesheet.
Composes with
| Component | Relation | Note |
|---|---|---|
CodeEditor | alternative | CodeEditor for code; RichTextEditor for prose. |
Form | parent | Treat as a controlled form input. |
TextInput | sibling | |
Textarea | sibling |
Prompt examples
These are the AI prompt → JSX mappings used by the helix-ui prompt DSL and integrations like Cursor / Claude Code.
blog post editor
“WYSIWYG editor for a post body”
<RichTextEditor value={body} onChange={setBody} placeholder="Write…" />