Skip to content

RichTextEditor

RichTextEditor

A single, lazy-loaded WYSIWYG surface backed by TipTap — which is itself a thin ergonomic layer over ProseMirror. The base @helixui/core bundle does not pull TipTap until a <RichTextEditor> actually mounts.

Install only what you use. Starter kit covers most documents:

Terminal window
pnpm add @tiptap/core @tiptap/pm @tiptap/starter-kit

Per-extension peers — add the ones you actually enable:

Terminal window
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-image

Basic

import { useState } from 'react';
import { RichTextEditor } from '@helixui/core';
const [html, setHtml] = useState('<p>Hello, <strong>helixui</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', 'helixui')}>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 helixui tokens, so the editor inherits the active DNA theme automatically.

Mobile

Below the helixui mobile breakpoint (≤767px):

  • Toolbar gains larger touch targets (32×32 buttons).
  • Surface padding tightens.
  • Default font size drops to --helixui-font-size-sm.

Install: @helixui/core

import { RichTextEditor } from '@helixui/core'

status: stable · since: 0.7.0

Tags: wysiwyg, editor, tiptap, prosemirror

Anatomy

┌── toolbar (B I U • ¶ ) ──┐
├──────────────────────────┤
│ editable content area │
│ … │
└──────────────────────────┘

Layout

  • displayblock
  • widthfill
  • heightfill
  • intrinsicSizefills available; toolbar pinned top
  • stackablefalse
  • fullBleedfalse

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

NameTypeDefaultDescription
valuestring''HTML string for the current document.
onChange(html: string) => voidFires on every edit with the new HTML.
onChangeText(text: string) => voidPlain-text snapshot, fired alongside onChange.
onChangeJSON(json: unknown) => voidTipTap JSON snapshot, fired alongside onChange.
onSelectionChange(state: RichTextSelectionState) => voidFires whenever the selection or active formats change.
extensionsRichTextExtension[] | 'all'starterBuilt-in extensions to enable. Defaults to a starter preset; pass ‘all’ to enable everything.
theme'light' | 'dark' | 'auto'autoauto follows document.documentElement.dataset.theme.
readOnlybooleanfalseDisable editing.
autoFocusbooleanfalseFocus the editor when it mounts.
placeholderstringWrite something…Empty-state hint. Requires the placeholder extension (on by default).
toolbarbooleantrueShow the built-in toolbar.
renderToolbar(handle, state) => ReactNodeRender a custom toolbar instead of the default. Receives the imperative handle and current selection state.
heightnumber | string360Editing surface height. Numbers are treated as px.
editorRefRef<RichTextEditorHandle>Imperative handle for advanced control.
tiptapExtensionsunknown[][]Extra TipTap extensions appended after the built-in ones.
onMount(handle: RichTextEditorHandle) => voidFires 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-pressed for active states.
  • Container shows a :focus-within ring matching helixui’s other inputs.
  • Respects prefers-reduced-motion via the global motion stylesheet.

Composes with

ComponentRelationNote
CodeEditoralternativeCodeEditor for code; RichTextEditor for prose.
FormparentTreat as a controlled form input.
TextInputsibling
Textareasibling

Prompt examples

These are the AI prompt → JSX mappings used by the helixui prompt DSL and integrations like Cursor / Claude Code.

blog post editor

“WYSIWYG editor for a post body”

<RichTextEditor value={body} onChange={setBody} placeholder="Write…" />