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 @helix-ui/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 '@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

  • 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 helix-ui’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 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…" />