Rich Text
Headless rich-text editor: a typed document model (schema-declared node and mark types), immutable transactions and commands with undo history, input rules, sanitized HTML paste and serialization, and contenteditable parts.
| Package | @vizejs/ui/rich-text |
| Maturity | stable |
| Own the source | vize lib pull rich-text |
| Requires | collection, context, id, positioner |
| Aliases | wysiwyg, rich text editor, contenteditable editor, prose editor |
| Covers | ProseMirror model, Tiptap, Lexical, Slate, WAI-ARIA textbox |
Minimal setup
Use a Vue 3.5+ project with Vue SFC compilation configured (see Vite+ integration).
vp install @vizejs/ui
Add the public import below to <script setup lang="ts">. Components provide behavior without styles; the preview uses optional Paper styles.
import { RichTextRoot, RichText, RichTextBubbleMenu, RichTextContent, RichTextToolbar, RichTextToolbarButton } from "@vizejs/ui/rich-text";
Or copy the source into your project with vize lib pull rich-text (see Source Distribution).
Try it
This is the basic example maintained with the component source, using the public package imports. The preview adds the optional Paper styles.
Open the interactive example · Styling these components
Copy the example
<!-- Rich-text editor with a formatting toolbar, a selection bubble menu, and v-model HTML output. -->
<script setup lang="ts">
import { computed, ref } from "vue";
import {
RichTextBubbleMenu,
RichTextContent,
RichTextRoot,
RichTextToolbar,
RichTextToolbarButton,
createDefaultRichTextSchema,
createRichTextCommands,
isBlockActive,
isMarkActive,
richTextDocFromText,
richTextToHtml,
} from "@vizejs/ui/rich-text";
const schema = createDefaultRichTextSchema();
const commands = createRichTextCommands(schema);
const doc = ref(
richTextDocFromText(schema, "Release notes\nType ## for a heading or - for a list."),
);
const html = computed(() => richTextToHtml(schema, doc.value));
</script>
<template>
<RichTextRoot v-model="doc" :schema>
<RichTextToolbar aria-label="Formatting">
<RichTextToolbarButton
:command="commands.toggleMark('bold')"
:active="(state) => isMarkActive(state, 'bold')"
aria-label="Bold"
>
B
</RichTextToolbarButton>
<RichTextToolbarButton
:command="commands.toggleBlockType('heading', { level: 2 })"
:active="(state) => isBlockActive(state, 'heading', { level: 2 })"
aria-label="Heading"
>
H2
</RichTextToolbarButton>
<RichTextToolbarButton :command="commands.toggleWrap('bulletList')" aria-label="Bullet list">
List
</RichTextToolbarButton>
<RichTextToolbarButton :command="commands.undo" aria-label="Undo">Undo</RichTextToolbarButton>
</RichTextToolbar>
<RichTextContent aria-label="Release notes" placeholder="Write something…" />
<RichTextBubbleMenu>
<RichTextToolbarButton :command="commands.toggleMark('italic')" aria-label="Italic">
I
</RichTextToolbarButton>
</RichTextBubbleMenu>
</RichTextRoot>
<output>{{ html }}</output>
</template>
API
RichTextRoot<Schema extends RichTextSchema>
Source: src/families/editor/rich-text/rich-text-root.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
schema (required) |
Schema |
undefined |
Schema declaring node and mark types; documents and commands are typed from it. |
modelValue |
RichTextDoc<Schema> |
undefined |
Controlled document (v-model). undefined selects uncontrolled behavior. |
defaultValue |
RichTextDoc<Schema> |
one empty default block |
Initial uncontrolled document. |
id |
string | null |
undefined |
Consumer-owned editor id. null and undefined select a deterministic fallback. |
editable |
boolean |
true |
Allow editing; false renders read-only content. |
inputRules |
readonly RichTextInputRule[] |
defaultRichTextInputRules(schema) |
Input rules applied to typed text. |
keymap |
RichTextKeymap |
defaultRichTextKeymap(schema) |
Keyboard shortcuts. |
historyDepth |
number |
100 |
Maximum undo depth. |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue |
[value: RichTextDoc<Schema>] |
Fired with the new document after every change (supports v-model). |
transaction |
[transaction: RichTextTransaction] |
Fired for every transaction, including selection-only changes. |
Slots
| Slot | Slot props | Description |
|---|---|---|
default |
RichTextSlotProps<Schema> |
Editor parts (content, toolbars, bubble menus). Receives the live state and commands. |
Exposed
| Member | Type | Description |
|---|---|---|
state |
RichTextState<Schema> |
Current editor state. |
run |
(command: RichTextCommand) => boolean |
Run a command; returns whether it applied. |
focus |
() => void |
Focus the editable content. |
toHtml |
() => string |
Serialize the document to sanitized HTML. |
RichText<Schema extends RichTextSchema>
Source: src/families/editor/rich-text/rich-text-root.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
schema (required) |
Schema |
undefined |
Schema declaring node and mark types; documents and commands are typed from it. |
modelValue |
RichTextDoc<Schema> |
undefined |
Controlled document (v-model). undefined selects uncontrolled behavior. |
defaultValue |
RichTextDoc<Schema> |
one empty default block |
Initial uncontrolled document. |
id |
string | null |
undefined |
Consumer-owned editor id. null and undefined select a deterministic fallback. |
editable |
boolean |
true |
Allow editing; false renders read-only content. |
inputRules |
readonly RichTextInputRule[] |
defaultRichTextInputRules(schema) |
Input rules applied to typed text. |
keymap |
RichTextKeymap |
defaultRichTextKeymap(schema) |
Keyboard shortcuts. |
historyDepth |
number |
100 |
Maximum undo depth. |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue |
[value: RichTextDoc<Schema>] |
Fired with the new document after every change (supports v-model). |
transaction |
[transaction: RichTextTransaction] |
Fired for every transaction, including selection-only changes. |
Slots
| Slot | Slot props | Description |
|---|---|---|
default |
RichTextSlotProps<Schema> |
Editor parts (content, toolbars, bubble menus). Receives the live state and commands. |
RichTextBubbleMenu
Source: src/families/editor/rich-text/rich-text-bubble-menu.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabel |
string |
"Selection formatting" |
Accessible name of the floating toolbar. |
placement |
Placement |
"top" |
Preferred placement relative to the selection. |
offset |
number |
8 |
Gap between the selection and the menu in CSS pixels. |
open |
boolean |
undefined |
Force visibility; undefined shows the menu for a non-empty selection while editing. |
Slots
| Slot | Slot props | Description |
|---|---|---|
default |
— | Toolbar buttons for the selection. |
Exposed
| Member | Type | Description |
|---|---|---|
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
RichTextContent
Source: src/families/editor/rich-text/rich-text-content.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabel |
string |
undefined |
Accessible name of the text box. |
ariaLabelledby |
string |
undefined |
Ids that label the text box. |
ariaDescribedby |
string |
undefined |
Ids that describe the text box. |
placeholder |
string |
undefined |
Placeholder published as data-placeholder (and aria-placeholder) while empty. |
spellcheck |
boolean |
true |
Native spellchecking. |
Exposed
| Member | Type | Description |
|---|---|---|
element |
HTMLDivElement | null |
Rendered editable element. |
focus |
() => void |
Focus the editable element. |
RichTextToolbar
Source: src/families/editor/rich-text/rich-text-toolbar.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabel |
string |
"Formatting" |
Accessible name of the toolbar. |
Slots
| Slot | Slot props | Description |
|---|---|---|
default |
— | RichTextToolbarButton children (and separators or groups). |
Exposed
| Member | Type | Description |
|---|---|---|
element |
Readonly<ShallowRef<HTMLElement | null>> |
Template ref to the rendered root element. |
RichTextToolbarButton
Source: src/families/editor/rich-text/rich-text-toolbar-button.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
command (required) |
RichTextCommand |
undefined |
Command run on activation; the button disables itself while it cannot run. |
active |
(state: RtState) => boolean |
undefined |
Toggle-state predicate; when provided the button publishes aria-pressed. |
ariaLabel |
string |
undefined |
Accessible name when the content is an icon. |
disabled |
boolean |
false |
Force the disabled state. |
Slots
| Slot | Slot props | Description |
|---|---|---|
default |
RichTextToolbarButtonSlotProps |
Button contents. Receives active and disabled state. |
Behavior
@vizejs/ui/rich-text is a headless, contenteditable rich-text editor. The model (schema, documents,
transforms, transactions, history, commands, input rules, keymap, HTML) is pure TypeScript; the parts
rich-text-root.vue, rich-text-content.vue, rich-text-toolbar.vue, rich-text-toolbar-button.vue, and
rich-text-bubble-menu.vue bind it to the DOM. Every row is proven by the named test in rich-text.test.ts,
rich-text-model.test.ts, rich-text-html.test.ts, or rich-text-ssr.test.ts; typing guarantees live in
rich-text.types.test-d.ts.
| # | State | Input | Outcome | Proven by |
|---|---|---|---|---|
| R1 | any | render | rich-text-root.vue wraps rich-text-content.vue: role="textbox", aria-multiline, label, placeholder hooks, contenteditable, deterministic ids, editor hooks on every block |
renders an accessible multiline text box with editor hooks |
| R2 | DOM selection | map to/from model | positions round-trip through text, nested marks, inline leaves, list paths, and element-level points | DOM and model positions round-trip through text, marks, and leaves |
| R3 | editable | beforeinput insertText |
the browser mutation is prevented; the model applies the edit, re-renders, restores the caret, and emits update:modelValue |
beforeinput typing goes through the model, re-renders, and emits v-model |
| R4 | editable | typing, Enter, Backspace, Shift+Enter | input rules, block splitting, joining, and hard breaks run through the keymap | input rules, Enter, Backspace, and line breaks follow the keymap |
| R5 | selection | Mod-b, Mod-z, historyRedo |
formatting shortcuts, toolbar aria-pressed, undo and redo |
shortcuts format the selection and undo restores it |
| R6 | editable | paste | clipboard HTML is parsed into schema nodes; scripts and event handlers never reach the DOM | paste parses sanitized HTML and plain text into the document |
| R7 | IME | composition events | the IME owns the DOM while composing; compositionend commits once; cancelled compositions restore the model's DOM |
composition text is committed once at compositionend and the DOM is repaired |
| R8 | toolbar | state, mousedown, click, arrows, Alt+F10, Escape | rich-text-toolbar.vue and rich-text-toolbar-button.vue: disabled when a command cannot run, keep the selection, roving focus, Alt+F10 enters, Escape returns |
toolbar buttons reflect command state, keep focus, and rove with arrows |
| R9 | focused, non-empty selection | select | rich-text-bubble-menu.vue shows a positioned toolbar and hides for a caret |
the bubble menu appears for a focused non-empty selection |
| R10 | editable=false |
input, shortcuts | aria-readonly, no mutations or events, tools disabled |
read-only editors refuse input and disable tools |
| R11 | controlled | typing / prop changes | v-model round-trips the document |
controlled documents follow v-model and external replacements |
| R12 | SSR | two requests | byte-identical static, escaped, sanitized HTML; bubble menu hidden | renders the document as static, escaped, sanitized HTML on the server |
| R13 | SSR | hydrate | no mismatch diagnostics; content node reused | hydrates the rendered document without mismatches |
| R14 | schema | define | names, textblock default, and list/listItem pairs are validated | schemas validate names, a textblock default, and list pairs |
| R15 | URLs | links and images | only safe schemes and relative URLs render | URL guards allow safe schemes and relative URLs only |
| R16 | documents | validate | structural validation against the schema (roles, lists, code blocks, marks) | documents are validated structurally against the schema |
| R17 | inline | normalize / slice | equal-mark runs merge; offsets slice across runs | inline helpers merge equal marks and slice by offsets |
| R18 | caret | insertText / toggleMark | active and stored marks apply to typed text | typing inserts text with active and stored marks |
| R19 | link | type after it | non-inclusive marks do not extend; unsafe hrefs never render | links are not inclusive: typing after a link leaves it |
| R20 | range | toggleMark | marks toggle across blocks; isMarkActive reports |
marks toggle across multi-block selections and report activity |
| R21 | textblocks | setBlockType / toggle | type changes, toggles back, code blocks strip marks and refuse marks | block types change, toggle back, and code blocks strip marks |
| R22 | caret | splitBlock | split, headings continue as paragraphs, code inserts newlines | Enter splits blocks, leaves headings as paragraphs, and inserts newlines in code |
| R23 | list item | splitBlock | split items; an empty item leaves the list | Enter in lists splits items and leaves the list from an empty item |
| R24 | caret / range | deleteBackward | characters, surrogate pairs, words, selections, joins, heading reset | Backspace deletes characters, surrogate pairs, words, selections, and joins blocks |
| R25 | start of item/quote | deleteBackward | lift out of the first list item or quote; later items merge | Backspace at the start of a list item or quote lifts it out |
| R26 | caret | deleteForward | next character or merge the next block | Delete removes the next character or merges the next block |
| R27 | blocks | wrapIn / toggleWrap | lists and quotes wrap sibling blocks, unwrap, and map the selection | wrapping in lists and quotes, toggling off, and mapping the selection |
| R28 | caret | insertInline | images and hard breaks insert with sanitized attributes | inline leaves insert at the caret with sanitized attributes |
| R29 | history | typing / format / undo / redo | typing groups into one step, formatting is separate, redo replays | history groups typing, separates formatting, and supports redo |
| R30 | transactions | create | selections clamp; invalid documents are rejected | transactions clamp selections and reject invalid documents |
| R31 | small schema | undeclared types | commands for undeclared types do not apply | commands for undeclared types are rejected at runtime |
| R32 | paste model | insertContent | edge textblocks merge, middle blocks insert, code blocks receive plain text, list items split | inserting content merges edge textblocks and keeps middle blocks |
| R33 | serializer | toHtml | escaped text, shared mark elements, editor hooks and caret fillers | serializes escaped text, nested marks, and editor hooks |
| R34 | parser | fromHtml | schema nodes survive; scripts, handlers, unsafe URLs, and unknown elements are dropped or unwrapped | parses pasted HTML into schema nodes and drops everything else |
| R35 | parser | empty input | one empty default block | parsing an empty fragment yields one empty block |
| R36 | typing | markdown prefixes | #, >, -, 1., and ``` rules build structure only at block start |
block input rules turn markdown prefixes into structure |
| R37 | typing | delimiters | **, _, `, and ~~ wrap text in marks |
mark input rules wrap delimited text and stop extending it |
| R38 | code block | typing | no rules in code; a rule undoes in one step | input rules are skipped in code blocks and undo as one step |
| R39 | keys | normalize | Mod covers Ctrl/Cmd; physical digits for Alt chords |
key names normalize modifiers and physical digits |
| R40 | keymap | defaults | marks, headings, lists, quotes, code, history, and editing keys | the default keymap binds marks, headings, lists, and history |
Notes
- The browser never mutates the document:
beforeinputis prevented and translated into commands. Composition is the exception; the IME's text is committed atcompositionendand the DOM is re-rendered from the model. - Content renders through
v-htmlofrichTextToHtml(…, { editor: true }), which escapes text, drops event attributes, and passes every attribute through the schema's render specs (links and images are URL-checked). - Collaboration, tables, and drag-and-drop of content are out of scope for v1.