Skip to content

TipTap — the cross-framework rich-text editor

TipTap is Rozie's data-bound port of TipTap — the headless, ProseMirror-based rich-text editor. The official ecosystem is uneven: @tiptap/react and @tiptap/vue-3 are first-party, svelte-tiptap and ngx-tiptap are healthy community packages, solid-tiptap is thin and stalling, and Lit has no wrapper at all. Rozie ships the same <TipTap> (same props, events, two-way html binding, and imperative handle) in React, Vue, Svelte, Angular, Solid, and Lit. Notably, neither official wrapper ships a controlled two-way content contract or a toolbar; Rozie does. See the TipTap libraries comparison for the full matrix.

This page is the show-and-tell: the API surface, per-framework quick starts, the events, the imperative command handle, the editorProps/extensions passthroughs, the per-target recipe for the toolbar / bubbleMenu / floatingMenu portal slots, and the nodeView reactive portal slot that renders a framework fragment as a custom ProseMirror node (mention chips, embeds, editable callouts) on all six targets.

The full source for TipTap.rozie lives in the @rozie-ui/tiptap package.

The @rozie-ui/tiptap packages

TipTap ships as six pre-compiled, per-framework packages; install only the one for your framework. There is no build step and no Rozie toolchain to add:

PackageInstallREADME
@rozie-ui/tiptap-reactnpm i @rozie-ui/tiptap-reactreact/README
@rozie-ui/tiptap-vuenpm i @rozie-ui/tiptap-vuevue/README
@rozie-ui/tiptap-sveltenpm i @rozie-ui/tiptap-sveltesvelte/README
@rozie-ui/tiptap-angularnpm i @rozie-ui/tiptap-angularangular/README
@rozie-ui/tiptap-solidnpm i @rozie-ui/tiptap-solidsolid/README
@rozie-ui/tiptap-litnpm i @rozie-ui/tiptap-litlit/README

Each package carries the three @tiptap/* engine peers@tiptap/core, @tiptap/starter-kit, and @tiptap/extensions (it supplies the bundled Placeholder; all ^3) — plus its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common + @angular/forms, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core). Install the engine peers alongside the framework package:

bash
npm i @rozie-ui/tiptap-react @tiptap/core @tiptap/starter-kit @tiptap/extensions

TipTap is built from ProseMirror, which is framework-agnostic — the official wrappers exist only to glue onUpdate to component state and forward extensions. Rozie's wrapper does that plus a controlled two-way html binding (with an echo-guard), a batteries-included toolbar (or bring your own via the toolbar slot), a full imperative command handle, and two consumer-extensibility passthroughs (editorProps for ProseMirror, extensions for extra TipTap extensions composed onto StarterKit).

Quick start

The two-way value is html — the editor's document as an HTML string. Typing writes the new HTML back through the two-way path (TipTap's onUpdate), and a consumer write reflects into the live document (echo-guarded so a programmatic set doesn't reset the selection). The wrapper also emits update / selectionUpdate / focus / blur events.

React

tsx
import { useState } from 'react';
import { TipTap } from '@rozie-ui/tiptap-react';

export function Demo() {
  const [html, setHtml] = useState('<p>Hello <strong>world</strong></p>');
  return (
    <TipTap
      html={html}
      onHtmlChange={setHtml}
      placeholder="Start writing…"
      onUpdate={(html) => console.log('changed', html)}
    />
  );
}

Vue

vue
<script setup lang="ts">
import { ref } from 'vue';
import TipTap from '@rozie-ui/tiptap-vue';

const html = ref('<p>Hello <strong>world</strong></p>');
</script>

<template>
  <TipTap v-model:html="html" placeholder="Start writing…" />
</template>

Svelte

svelte
<script lang="ts">
  import TipTap from '@rozie-ui/tiptap-svelte';

  let html = $state('<p>Hello <strong>world</strong></p>');
</script>

<TipTap bind:html placeholder="Start writing…" />

Angular

ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TipTap } from '@rozie-ui/tiptap-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [TipTap, FormsModule],
  template: `
    <TipTap [(html)]="html" placeholder="Start writing…" />
  `,
})
export class DemoComponent {
  html = '<p>Hello <strong>world</strong></p>';
}

Solid

tsx
import { createSignal } from 'solid-js';
import { TipTap } from '@rozie-ui/tiptap-solid';

export function Demo() {
  const [html, setHtml] = createSignal('<p>Hello <strong>world</strong></p>');
  return <TipTap html={html()} onHtmlChange={setHtml} placeholder="Start writing…" />;
}

Lit

ts
import '@rozie-ui/tiptap-lit';

// <rozie-tip-tap> is a custom element. Bind `html` as a property and listen for
// the two-way `html-change` event.
const el = document.querySelector('rozie-tip-tap');
el.html = '<p>Hello <strong>world</strong></p>';
el.placeholder = 'Start writing…';
el.addEventListener('html-change', (e) => {
  el.html = e.detail;
});

API

Props

NameTypeDefaultTwo-way (model)Description
htmlString"<p>Start writing…</p>"The two-way document content as an HTML string. Typing writes back through the model path; a consumer write reflects into the live document (echo-guarded so a programmatic set doesn't reset the selection or re-emit update).
editableBooleantrueWhether the document is editable. Toggling it calls TipTap's setEditable with emitUpdate: false (no spurious update). When false, the internal toolbar is hidden and the wrapper gets an is-readonly class.
placeholderString""Placeholder text, forwarded to the editor host as data-placeholder + aria-placeholder and painted as ghost text on the first empty node via the bundled Placeholder extension; no :extensions wiring is needed (see Placeholder rendering is bundled). An empty string adds no placeholder.
autofocusBooleanfalseWhether to place the caret in the document on mount (TipTap's autofocus option).
editorClassString""A CSS class applied to the contenteditable element (editorProps.attributes.class).
ariaLabelString"Rich text editor"The aria-label applied to the contenteditable element.
editorPropsObject{}ProseMirror editorProps passthrough — handleKeyDown, handlePaste, custom attributes, etc. Spread last so consumer editorProps win the wrapper's attribute defaults.
extensionsArray[]Extra TipTap extensions composed onto StarterKit — the consumer-extensibility passthrough (TipTap's analog of an options bag). Consumer extensions genuinely win for a StarterKit-bundled node/mark: a same-named custom extension (e.g. a custom Link) auto-disables the corresponding StarterKit key, and the final extension array is name-deduped keeping the last (consumer) occurrence — no "Duplicate extension names" warning. Add Placeholder, Link, Image, Mention, custom nodes/marks, etc.
starterKitObject{}StarterKit config passthrough — spread into StarterKit.configure(...). Accepts per-extension option objects or false to disable an extension, e.g. { heading: false }, { link: { openOnClick: false } }. An explicitly-set key here is always respected and never overridden by the extensions auto-disable scan.
nodeSpecsArray[]Custom ProseMirror node registration for the reactive nodeView portal slot — a general facility, read once at mount. Each entry: { name, tag, group, inline, atom, content, selectable, defining, attrs }. One Node.create is built per entry; all render through the SAME nodeView fragment, dispatching on scope.node.type.name. An empty array (default) registers no custom nodes — zero overhead.
uploadImageFunctionnullAn async image-upload hook, (file: File) => Promise<string> resolving to a URL. When provided, the (otherwise-absent) Image extension is registered and pasting/dropping an image file uploads it via this function then inserts the resolved URL at the caret / drop position. When null (default), no Image extension and paste/drop are unchanged — zero overhead. Acts as a fallback: a consumer-supplied editorProps.handlePaste / handleDrop still wins.
maxLengthNumbernullA soft character-count threshold. null (default) registers no CharacterCount extension and renders no counter — zero overhead. A number registers CharacterCount (see enforceMaxLength) and renders a live characters / maxLength counter (overridable via the #count slot); once the character count exceeds it, the counter gets the over state. Overflow is still allowed unless enforceMaxLength is also set.
enforceMaxLengthBooleanfalseOpts into a hard cap at maxLength. When true and maxLength is set, CharacterCount is configured with { limit: maxLength }, so ProseMirror itself refuses input past the limit — no overflow ever reaches the document. When false (default), the counter still tracks and surfaces the over state past maxLength, but typing/pasting is never blocked. Has no effect when maxLength is null.
bubbleMenuShouldShowFunctionnullA custom shouldShow predicate for the general bubbleMenu slot — the TipTap signature ({ editor, view, state, oldState, from, to }) => boolean. When provided it replaces the default (show on a non-empty text selection), making bubbleMenu a fully consumer-controllable selection-tooling surface; when null (default) the default behavior applies. Orthogonal to the built-in link editor, which is its own bubble-menu surface with a link-aware trigger. As a Function prop it lowers to a loosely-typed callable on some targets — pass a correctly-typed predicate; the wrapper forwards it verbatim to BubbleMenu.configure({ shouldShow }).

Events

EventPayloadDescription
updatestringThe document changed — the new HTML string. (Also the channel that drives the two-way html model.)
selectionUpdateThe selection (caret / range) moved.
focusThe editor gained focus.
blurThe editor lost focus.

Imperative handle

Beyond props, the component exposes imperative methods declared once in the Rozie source via $expose. Grab a handle with your framework's native ref mechanism (React useRef / Vue template ref / Svelte bind:this / Angular viewChild / Solid callback ref / the Lit custom element itself) and call them directly:

MethodDescription
getEditorReturn the underlying TipTap Editor for direct API access (commands, state, schema). null before mount and after destroy.
focusEditorFocus the editor — place the caret in the document.
blurEditorBlur the editor — remove focus.
getHTMLReturn the current document serialized as an HTML string.
getJSONReturn the current document as a ProseMirror JSON object.
getTextReturn the current document as plain text (TipTap's getText(); marks and structure stripped). '' before mount.
setContentReplace the document — setContent(html). Echo-guarded: reflects into the bound html model without bouncing an extra update.
clearContentClear the document to an empty paragraph (reflects the empty value into the html model).
toggleBoldToggle bold on the current selection.
toggleItalicToggle italic on the current selection.
toggleHeadingToggle a heading at a level — toggleHeading(level) (defaults to 1).
toggleBulletListToggle a bullet list at the current selection.
toggleUnderlineToggle underline on the current selection.
toggleOrderedListToggle an ordered (numbered) list at the current selection.
undoUndo the last change.
redoRedo the last undone change.
chainReturn a focused TipTap command chain for composing commands — chain().toggleBold().toggleItalic().run(). null before mount.
isActiveWhether a mark/node is active in the current selection — isActive(name, attrs?). Drives custom-toolbar active styling. false before mount.
canReturn the command-availability chain — can().chain().focus().toggleBold().run() returns a boolean — for enabling/disabling custom-toolbar buttons. null before mount.
isEmptyWhether the document is empty — drives empty-state UI and submit-gating. true before mount.
getCharacterCountReturn the current character count. Reads the CharacterCount extension's live storage when registered (maxLength set or the #count slot filled), else falls back to getText().length. Always a number — 0 before mount.
getWordCountReturn the current word count. Reads the CharacterCount extension's live storage when registered, else falls back to a whitespace-split count of getText(). Always a number — 0 before mount.
openLinkEditorOpen the link editor on the current selection (create mode) — the imperative equivalent of clicking the toolbar Link button. Surfaces the editor prefilled with any existing link href; no-op before mount.
setLinkApply or replace a link on the current selection — setLink({ href }), with any additional stock attrs (target, rel, class, title) forwarded verbatim. An attrs object without a non-empty href is ignored. No-op before mount.
unsetLinkRemove the link mark from the current selection. No-op before mount.

The focus/blur verbs are focusEditor / blurEditor, not focus / blur

The component emits focus and blur events, and on class-based targets (Angular) an output field and a method cannot share a name. The imperative verbs are therefore named focusEditor / blurEditor, keeping the plain focus / blur event names for consumers. Likewise the content setter is setContent, not setHtml — an html model prop makes the React target auto-generate a setHtml state setter.

React example:

tsx
import { useRef } from 'react';
import { TipTap, type TipTapHandle } from '@rozie-ui/tiptap-react';

const editor = useRef<TipTapHandle>(null);
// <TipTap ref={editor} ... />
editor.current?.toggleBold();
const html = editor.current?.getHTML();
editor.current?.chain()?.toggleItalic().toggleBulletList().run();

Slots

The wrapper surfaces five portal slots plus a reactive scoped slot. Three of the portal slots are mount-oncetoolbar, bubbleMenu, floatingMenu — each handed the live editor so its buttons can drive editor.chain().focus()…run(). The other two, nodeView and linkEditor, are reactive portal slots (covered in Node-view slots and Link editor below). Fill toolbar and your toolbar UI replaces the internal one; leave it unfilled and the batteries-included internal toolbar (Bold / Italic / H1 / H2 / Bullet list / Underline / Ordered list / Link, with live active-state highlighting, plus Undo / Redo) renders.

SlotRendersScope param
toolbarA consumer toolbar above the editor (replaces the internal one)editor
bubbleMenuA consumer menu shown on a non-empty text selection (over @tiptap/extension-bubble-menu); the trigger is customisable via :bubble-menu-should-showeditor
floatingMenuA consumer menu shown on an empty line (over @tiptap/extension-floating-menu)editor
linkEditorA consumer link-editing form in the link editor's bubble-menu surface (replaces the built-in form); reactive — reflects the current linkeditor, href, attrs, setLink, unsetLink, close
countA consumer character/word-counter display (replaces the built-in characters / maxLength counter) — a plain reactive scoped slot, not a portal; renders whenever maxLength is set or the slot is filledcharacters, words, maxLength, over

Character/word count slot

Set :max-length to get a live, batteries-included characters / maxLength counter under the document — no #count slot required. It renders zero markup when maxLength is unset and the slot is unfilled (zero overhead, no VR drift). Soft mode (the default) tracks past the limit and adds the over state; :enforce-max-length="true" opts into a hard cap so overflow never reaches the document.

vue
<TipTap v-model:html="html" :max-length="500" enforce-max-length>
  <template #count="{ characters, words, maxLength, over }">
    <span :class="{ over }">{{ characters }} / {{ maxLength }} chars · {{ words }} words</span>
  </template>
</TipTap>

Read the same numbers imperatively at any time via the getCharacterCount() / getWordCount() handle methods (both return 0 before mount).

TipTap is headless — it ships the link behavior (the Link mark bundled in StarterKit) but no link-editing UI. The wrapper adds a batteries-included link editor: a small floating form anchored to the selection over its own dedicated bubble-menu surface (a distinct pluginKey, orthogonal to the general bubbleMenu slot). It surfaces two ways:

  • Toolbar Link button — the internal toolbar's Link button opens the editor on the current selection (create), and shows active when the cursor is on a link.
  • Cursor-on-link — moving the caret into an existing link surfaces the editor, prefilled with its href; Remove clears it. Enter applies, Escape cancels.

A stock <TipTap> gets all of this with zero configuration. To replace the built-in form with your own UI, fill the reactive #linkEditor slot — it renders in the same bubble-menu surface and re-renders as the selection/link changes, with { editor, href, attrs, setLink, unsetLink, close } in scope:

vue
<TipTap v-model:html="html">
  <template #linkEditor="{ href, attrs, setLink, unsetLink, close }">
    <input :value="href" @keydown.enter="e => setLink({ href: e.target.value })" />
    <button @click="unsetLink">Remove</button>
    <button @click="close">Done</button>
  </template>
</TipTap>

setLink(attrs) forwards its attrs object verbatim to TipTap, so you can attach custom data to a link — e.g. a course-link picker writing setLink({ href, 'data-course-link': id }). Note: the stock Link mark only persists href/target/rel/class/title — to persist a custom attribute like data-course-link, register an extended Link that declares it in its schema via :extensions:

ts
import Link from '@tiptap/extension-link';
const CourseLink = Link.extend({
  addAttributes() {
    return { ...this.parent?.(), 'data-course-link': { default: null } };
  },
});
// <TipTap :extensions="[CourseLink]"> … setLink({ href, 'data-course-link': id }) now persists

Open the editor imperatively (e.g. from your own toolbar) with the openLinkEditor() handle method, and drive links from your own toolbar without dropping to getEditor() and re-deriving the extendMarkRange('link') chain: setLink(attrs) / unsetLink() on the handle call the exact same applyLink/removeLink implementation the #linkEditor slot scope hands a consumer fragment, so the two paths can never disagree. The link editor only shows on a link (edit) or after the Link button/openLinkEditor() (create), and never on a bare selection, so it stays out of the way of the general bubbleMenu slot's default (non-empty-selection) trigger. The one overlap: if you fill #bubbleMenu and invoke the create affordance on a non-empty selection, both surfaces can appear over that selection — give your #bubbleMenu a :bubble-menu-should-show that excludes it (e.g. only show inside a table, or only when no link is being created) to keep them fully disjoint.

Each target fills #toolbar through its native imperative-render API:

React (render prop):

tsx
<TipTap
  html={html}
  onHtmlChange={setHtml}
  renderToolbar={({ editor }) => (
    <button onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
  )}
/>

Solid (render prop):

tsx
<TipTap
  html={html()}
  onHtmlChange={setHtml}
  renderToolbar={({ editor }) => (
    <button onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
  )}
/>

Vue (scoped slot):

vue
<TipTap v-model:html="html">
  <template #toolbar="{ editor }">
    <button @click="editor.chain().focus().toggleBold().run()">Bold</button>
  </template>
</TipTap>

Svelte (snippet):

svelte
<TipTap bind:html>
  {#snippet toolbar({ editor })}
    <button onclick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
  {/snippet}
</TipTap>

Angular (content child <ng-template>):

html
<TipTap [(html)]="html">
  <ng-template #toolbar let-editor="editor">
    <button (click)="editor.chain().focus().toggleBold().run()">Bold</button>
  </ng-template>
</TipTap>

Lit (slot bridge — pass the render callback as a property):

ts
const el = document.querySelector('rozie-tip-tap');
el.toolbar = ({ editor }) =>
  html`<button @click=${() => editor.chain().focus().toggleBold().run()}>Bold</button>`;

On every target the wrapper's $portals.toolbar(node, { editor }) closure mounts the consumer's fragment into the toolbar host container and returns a dispose handle the wrapper calls on unmount.

Bubble & floating menu slots

The bubbleMenu and floatingMenu slots are selection-anchored menus over TipTap's Floating-UI menu extensions (@tiptap/extension-bubble-menu / @tiptap/extension-floating-menu). They use the same mount-once portal shape as toolbar and receive the live editor — but the menu's host element is created by the wrapper and positioned by Floating UI, so you only supply the menu fragment. By default the bubble menu appears on a non-empty text selection and the floating menu on an empty line. Each menu extension is added only when its slot is filled (zero overhead otherwise), and the two extension peers are declared optional on every leaf package — install them only if you use the slots.

The fill API mirrors toolbar exactly — renderBubbleMenu / renderFloatingMenu render props (React/Solid), #bubbleMenu / #floatingMenu scoped slots (Vue) / snippets (Svelte) / <ng-template> content children (Angular), or bubbleMenu / floatingMenu properties on the Lit element:

Vue:

vue
<TipTap v-model:html="html">
  <template #bubbleMenu="{ editor }">
    <button @click="editor.chain().focus().toggleBold().run()">Bold</button>
    <button @click="editor.chain().focus().toggleItalic().run()">Italic</button>
  </template>
  <template #floatingMenu="{ editor }">
    <button @click="editor.chain().focus().toggleHeading({ level: 1 }).run()">H1</button>
  </template>
</TipTap>

React:

tsx
<TipTap
  html={html}
  onHtmlChange={setHtml}
  renderBubbleMenu={({ editor }) => (
    <button onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
  )}
  renderFloatingMenu={({ editor }) => (
    <button onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>H1</button>
  )}
/>

On every target the wrapper's $portals.bubbleMenu(node, { editor }) / $portals.floatingMenu(node, { editor }) closures mount the consumer's menu fragment into the engine-owned (imperatively created) menu host and return a dispose handle called on unmount.

Node-view slots

TipTap's marquee feature is the node view: rendering a framework fragment as a custom ProseMirror node (a mention chip, an embed, an editable callout). @rozie-ui/tiptap ships it as the nodeView slot, a reactive portal slot whose fragment re-renders in place (no remount) on every engine transaction, with { node, selected, updateAttributes, getPos, editor, contentDOM } in scope and two bundled custom nodes (rozieMention, rozieCallout) ready to render. The full deep dive (engine-driven re-render, the contentDOM editable-hole recipe, and the per-target consumer shapes) lives on Node-view slots.

Recipes

Driving the editor from the toolbar handle

The $expose verbs cover the imperative surface props alone can't express. Grab the handle and wire your own external toolbar — without the toolbar slot — by calling the command verbs directly:

tsx
const editor = useRef<TipTapHandle>(null);
// <TipTap ref={editor} ... />
<button onClick={() => editor.current?.toggleBold()}>Bold</button>
<button onClick={() => editor.current?.toggleHeading(2)}>H2</button>
<button onClick={() => editor.current?.undo()}>Undo</button>
<button onClick={() => console.log(editor.current?.getJSON())}>Log JSON</button>

Adding extensions via :extensions

StarterKit is the bundled baseline. Everything else — Placeholder, Link, Image, TextAlign, Mention, custom nodes/marks — comes through the :extensions passthrough. The wrapper composes consumer extensions last so they win for the same node/mark — and when your extension replaces one StarterKit already bundles (e.g. a custom Link), the wrapper auto-disables StarterKit's copy via StarterKit.configure({ link: false }) so you get no "Duplicate extension names" warning and your extension's schema/attrs are the ones that render:

bash
npm i @tiptap/extension-placeholder @tiptap/extension-link
vue
<script setup lang="ts">
import { ref } from 'vue';
import TipTap from '@rozie-ui/tiptap-vue';
import Placeholder from '@tiptap/extension-placeholder';
import Link from '@tiptap/extension-link';

const html = ref('<p></p>');
// A custom Link replaces StarterKit's bundled Link automatically — no
// `starterKit={{ link: false }}` needed, and no duplicate-extension warning.
const extensions = [Placeholder.configure({ placeholder: 'Write something…' }), Link.configure({ openOnClick: false })];
</script>

<template>
  <TipTap v-model:html="html" :extensions="extensions" />
</template>

Configuring StarterKit via :starterKit

Reach into any bundled StarterKit extension directly — no need to know which extension needs disabling for a custom replacement (the extensions auto-disable scan above already handles that case). Use starterKit when you just want to reconfigure or turn off a StarterKit-native extension in place:

vue
<script setup lang="ts">
import { ref } from 'vue';
import TipTap from '@rozie-ui/tiptap-vue';

const html = ref('<p></p>');
// Restrict headings to H1/H2 and turn off the bundled horizontal rule.
const starterKit = { heading: { levels: [1, 2] }, horizontalRule: false };
</script>

<template>
  <TipTap v-model:html="html" :starter-kit="starterKit" />
</template>

An explicit key in starterKit is always respected — it is never overridden by the extensions auto-disable scan, even if you also pass a same-named custom extension.

Customizing ProseMirror behavior via :editorProps

editorProps is forwarded straight to ProseMirror. Override paste handling, key bindings, or the contenteditable attributes:

tsx
<TipTap
  html={html}
  onHtmlChange={setHtml}
  editorProps={{
    handlePaste: (view, event) => {
      // custom paste handling; return true to mark as handled
      return false;
    },
    attributes: { class: 'prose max-w-none', spellcheck: 'false' },
  }}
/>

Gotchas

The echo-guard keeps two-way binding from ping-ponging

A model two-way binding can ping-pong: the consumer's state signals back into the wrapper's html watcher faster than the wrapper's own emit clears. The wrapper solves this once with a lastHtml guard shared by the html watcher, the onUpdate reflect, and the setContent / clearContent handle verbs. The guard compares against the raw last value (not editor.getHTML(), ProseMirror's normalized serialization), so a mount-time or prop-driven set never re-runs setContent and resets the selection.

Why focus / blur are events but the verbs are renamed

focus and blur are emitted as events (so consumers can wire save-on-blur or toolbar show/hide). Because an Angular output field and a method cannot share a name, the imperative commands are focusEditor / blurEditor. This keeps both capabilities — the focus/blur notifications and the imperative focus/blur control — alive.

Placeholder rendering is bundled

The placeholder prop renders empty-state ghost text out of the box — the text shows only while the document is empty and hides as you type. @rozie-ui/tiptap bundles @tiptap/extensions (ships Placeholder in v3) and wires the prop to Placeholder.configure({ placeholder }) at editor construction, so no consumer :extensions wiring is needed. The ghost-text CSS reaches the engine-rendered .is-editor-empty node (which carries no Rozie scope attribute) via the :root { } engine-DOM escape hatch. The prop still also forwards aria-placeholder for assistive tech.

Feature-complete versus the official wrappers

TipTap's marquee feature — custom node views — ships via the nodeView reactive slot, and selection-anchored bubble / floating menus ship via the bubbleMenu / floatingMenu slots, both uniformly (including Solid and Lit, where no upstream renderer exists). Together with the bundled Placeholder and the auto-emitted Angular ControlValueAccessor, that closes every meaningful gap versus the official wrappers. The one intentionally-unmatched item is switching the two-way model payload itself to JSON (ngx-tiptap's outputFormat) — read JSON off the getJSON() handle instead. See the comparison page for the full matrix.

Cross-references

Pre-1.0 — APIs may change between minor versions.