Forms

Rich Text Editor

A JSON-first rich text editor built on Tiptap, with a real ARIA toolbar, a soft character budget, and a Tab key that always lets you leave.

Usage

RichTextEditor is for prose a person writes and a product stores: a release note, a comment, a bio, a description. It is a Tiptap editor underneath, so the document model, the schema, the undo history and the input rules are ProseMirror's rather than ours.

The value is a Tiptap JSON document, not an HTML string. onValueChange hands you that document first and the derived views — html, text, isEmpty, characterCount, wordCount — beside it, so storing structured content never means parsing your own markup back out.

Tab always leaves the editor. The list extensions bind Tab to indent, which turns a list into a keyboard trap; that binding is removed here and indentation moves to Ctrl+] / Ctrl+[ (Cmd on macOS) and to the toolbar's Indent and Outdent buttons.

Every button is icon-only, so each one carries a tooltip naming it and, where the command has one, its keybinding. React Aria opens it on keyboard focus as well as hover, which is what keeps it from being a pointer-only affordance.

Every example on this page carries the same toolbar, so the only thing that changes between them is the one behaviour each is about. It reads left to right as history, marks, blocks, lists and links, indentation, then the two clears. Action buttons carry a persistent circular chip and toggle buttons stay bare until they are on, because an action has no on state and would otherwise sit there looking like a toggle that is permanently off. Indent and Outdent are the exception: they are structural edits belonging with the list buttons beside them, so they stay plain.

Anatomy

Import the RichTextEditor component and access all parts using dot notation. It ships from @blakeui/pro-react/editor rather than the package root: it is the only component built on Tiptap, and Tiptap is an optional peer, so keeping it out of the root barrel is what lets every other component install without ProseMirror.

import {RichTextEditor} from "@blakeui/pro-react/editor";

<RichTextEditor>
  <RichTextEditor.Shell>
    <RichTextEditor.Toolbar>
      <RichTextEditor.ToolbarGroup>
        <RichTextEditor.ToggleButton command="bold" />
        <RichTextEditor.ActionButton action="undo" />
        <RichTextEditor.LinkPopover>
          <RichTextEditor.LinkPopover.Trigger />
          <RichTextEditor.LinkPopover.Content>
            <RichTextEditor.LinkPopover.Input />
            <RichTextEditor.LinkPopover.Actions>
              <RichTextEditor.LinkPopover.UnsetButton />
              <RichTextEditor.LinkPopover.ApplyButton />
            </RichTextEditor.LinkPopover.Actions>
          </RichTextEditor.LinkPopover.Content>
        </RichTextEditor.LinkPopover>
      </RichTextEditor.ToolbarGroup>
      <RichTextEditor.ToolbarSeparator />
    </RichTextEditor.Toolbar>
    <RichTextEditor.Content />
    <RichTextEditor.Footer>
      <RichTextEditor.FooterText />
      <RichTextEditor.CharacterCount />
    </RichTextEditor.Footer>
  </RichTextEditor.Shell>
</RichTextEditor>;

Content is the only part whose position is fixed relative to nothing — put the toolbar above it or below it, keep the footer or drop it. Nothing React-owned may be rendered inside Content: ProseMirror reconciles that subtree itself.

LinkPopover goes wherever its trigger belongs in the toolbar. It portals its own panel out to document.body, so nesting it inside a ToolbarGroup costs nothing and keeps the trigger in the toolbar's roving order where it belongs.

Character Count

Controlled

Words
0
Characters
0
Nodes
heading - paragraph - bulletList

Custom Composition

Disabled And Read Only

Extensible Commands

Placeholder

CSS Classes

The root here is .rich-text-editor. Three stylesheets meet on these elements, and the order between them is most of the styling story.

@blakeui/styles dresses .toolbar, .toggle-button and .button, because the toolbar and its buttons are those free components — this page does not reimplement a toolbar, it takes React Aria's. Both layers live in @layer components and the Pro layer is imported second, so a rule of equal specificity wins on source order and nothing below needs doubling up. Two free rules are fought by name: .toolbar is a w-fit single-row grid, which would run past the shell rather than wrap, and .toggle-button is a 40px text pill where this toolbar wants a 32px square.

ProseMirror owns everything inside .rich-text-editor__prosemirror. Those nodes are not React elements and can never carry a class from a prop, so the document's type ramp is written as element selectors under the root — the one place in the component where that is right rather than lazy.

The shell is seamless. There is no rule under the toolbar and none above the footer; the shell's own stroke is the only border, and the three rows are told apart by padding plus the toolbar's vertical group separators. Nothing replaces those rules — not a lighter one, not a background step, not a shadow — because a seam that is only almost invisible reads worse than either a real division or none. Verified by rasterising each row's composited background through a canvas: toolbar, content and footer come out byte-identical in both themes, at 1.00:1.

The third stylesheet in play is React Aria's, once the link popover is open. That panel is portaled to document.body, so it is not a descendant of the root at run time: it inherits no custom property declared on .rich-text-editor, and an @container query written on it would resolve against <body>. Everything it needs is declared on its own class.

Base Classes

  • .rich-text-editor — The root. Declares every custom property below and stacks the shell and the live region as a flex column. Carries data-slot="rich-text-editor".
  • .rich-text-editor__shell — The painted box. The fill is --field-background, so an editable surface reads as a field rather than as a card that happens to hold text. The stroke is not a field token: --field-border resolves to transparent in both themes, because the house field is filled and shadowed rather than stroked, so a border drawn from it measured a 1.00:1 boundary — no edge at all. It takes --border instead, the settled bordered-flat stroke for a container surface. overflow: clip, so the three rows meet the corner cleanly. It is the only border in the component: see the seamless note below.

Element Classes

  • .rich-text-editor__toolbar — The role="toolbar" row. display: flex with flex-wrap: wrap, replacing the free .toolbar's single-row grid: nineteen controls have to wrap on a narrow container, not overflow it. Measured, it wraps to 2 rows at 1280 and 768 and 3 rows at 375, and never overflows.
  • .rich-text-editor__toolbar-group — A layout box for buttons that belong together. Deliberately not role="group" — an unnamed group announces as a group with no name, which is noise rather than structure. The semantic division between runs is the separator.
  • .rich-text-editor__toolbar-separator — The rule between two runs. The free Separator, which reads its orientation from the context the free Toolbar publishes, so it comes out upright inside a horizontal toolbar without being told.
  • .rich-text-editor__toolbar-button — Every button in the toolbar, toggle and action alike. A square, because the glyphs are square and a text-button pill wastes a third of a narrow toolbar.
  • .rich-text-editor__toolbar-button--action — The persistent chip on an ActionButton. Circular rather than the toggle's rounded rectangle, because a chip sharing that radius reads as a stuck toggle. Applied by the component from the command table, not by the call site — indent and outdent are marked plain there, since they are structural edits belonging with the list buttons rather than document-wide actions.
  • .rich-text-editor__content — The scroll box. Carries the minimum height, so an empty editor is still a target worth clicking.
  • .rich-text-editor__prosemirror — The contenteditable itself. It is the one class here applied through editorProps.attributes rather than a prop, because ProseMirror owns the element. outline: none — the focus ring goes on the shell instead, since ringing an element that fills the middle row would draw a rectangle inside the box. The placeholder inside it draws in --muted, not --field-placeholder: that token is theme-invariant and measures 3.67:1 on the dark field, under AA for text somebody is meant to read.
  • .rich-text-editor__footer — The strip under the content. justify-content: space-between, so a leading slot and the count sit at opposite ends.
  • .rich-text-editor__footer-text — The footer's leading slot. Takes the count's own type scale and muted ink, so the two ends read as one row.
  • .rich-text-editor__character-count — The budget readout, and the element aria-describedby points at when maxLength is set.
  • .rich-text-editor__tooltip — A toolbar button's tooltip. Portaled, like the link panel. The surface, corner, elevation and both motion curves come from .tooltip in @blakeui/styles and are not re-authored here; only the layout is, because that rule ships break-all, which would split a keybinding mid-symbol.
  • .rich-text-editor__tooltip-key — The keybinding inside it, muted against the label because the label is the answer and the shortcut is the footnote.
  • .rich-text-editor__link-popover — The floating link panel. Portaled, so it declares its own width and padding; the fill, elevation, corner and both halves of the reduced-motion opt-out come from .popover in @blakeui/styles and are not re-authored here.
  • .rich-text-editor__link-input — The URL field's TextField wrapper, stacking the label over the input.
  • .rich-text-editor__link-actions — The row the Apply and Remove buttons sit in.
  • .rich-text-editor__announcement — The polite live region. Clipped rather than hidden, because display: none and visibility: hidden both take a live region out of the accessibility tree and stop it announcing at all.

States

  • [data-disabled="true"] on the root — dims the shell and sets cursor: not-allowed. The editable region loses its tab stop with it, so the control has no tab stops left once its buttons are disabled too.
  • [data-readonly="true"] on the root — stops every command but keeps the region focusable and selectable, because read-only text still has to be reachable to be read and copied.
  • [data-selected="true"] on a toolbar button — React Aria's, and it rides alongside the real aria-pressed the ToggleButton sets. Never colour alone: the fill changes, the ink changes, and the pressed state reaches assistive tech as an attribute.
  • [data-over-limit="true"] on the count — the budget is exceeded. --danger is a fill token and fails as small text on the dark page, so this paints a soft pill (--danger-soft behind --danger-soft-foreground) rather than recolouring the text to the fill. The wording changes with it and the live region announces the crossing, so the state never rests on colour.
  • aria-expanded on the link trigger — React Aria's, from DialogTrigger, alongside an aria-controls that appears only while the panel is open. aria-haspopup="dialog" is written by this component: React Aria sets that attribute for menu and listbox only, on the reasoning that screen readers often announce any value as a menu.
  • :focus on the region — the ring lands on .rich-text-editor__shell via :has().

CSS Variables

All declared on .rich-text-editor, so overriding one on the root retunes the whole component, including the parts ProseMirror renders.

  • --rich-text-editor-content-min-height — The editable region's floor. 144px.
  • --rich-text-editor-content-padding — The document's inset. 12px.
  • --rich-text-editor-block-gap — The gap between two top-level blocks. 12px, and the one number that sets the document's rhythm.
  • --rich-text-editor-control-size — A toolbar button, on both axes. 32px, which clears a 24px target with room to spare.
  • --rich-text-editor-radius — The shell's corner. var(--radius-2xl).

Motion

There is no motion in this component, and that is deliberate rather than a gap. Everything below .rich-text-editor__prosemirror is reconciled by ProseMirror, which replaces nodes React never sees; a motion component mounted in there would be animating a subtree pulled out from under it. Keeping the animation in CSS on the chrome makes that impossible by construction rather than by convention.

The press affordance is the settled asymmetric timing: the press-in snaps (transition-duration: 0s) to scale: 0.92, and only the release eases back over 120ms on var(--ease-out). A symmetric press lags under the finger. It rides the standalone scale property rather than transform, which keeps it clear of the Tailwind transform variable chain the free button base sets up — and the transform: none that zeroes the free base's own press sits in a separate rule, because Lightning CSS folds a transform and a scale sharing one block into a single transform, and a folded transform is a property the reduced-motion opt-out below cannot reach.

The action chip is paint, not movement: a persistent --default fill and a circular radius, sharing the same hover, press and reduced-motion rules as every other toolbar button.

Neither the tooltip's motion nor the link popover's is authored here. Popover.Content composes .popover from @blakeui/styles, and Tooltip.Content composes .tooltip from the same place — both own their own curves, and both motion-reduce: variants already expand to the two halves of the opt-out. Overlay motion is fixed by a package version bump, never by a rule in a Pro component. Worth knowing about the tooltip's: its opt-out is animate-none, which removes the fade along with the movement, where this component's own rule keeps the fade. That is the free package's call, not this one's.

Everything else transitions colour only — the button fill and ink, the shell's border, the count — which under the settled rule needs no opt-out at all: there is no movement in it to remove. The reduced-motion opt-out therefore covers exactly one declaration, the press scale, and is dual: the explicit [data-reduce-motion="true"] hook and @media (prefers-reduced-motion: reduce), the media half scoped :not(…) so the two never double-apply. Gentler, never zero — the button still lights up on press, it just arrives in one frame.

API Reference

RichTextEditor

The root. Owns the Tiptap instance and publishes it to the parts, and deliberately does not subscribe to it — a root that re-rendered per transaction would re-render the whole subtree on every keystroke. Also accepts every native <div> attribute.

PropTypeDefaultDescription
childrenReactNodeThe parts. Required, and in practice always a single RichTextEditor.Shell.
valueJSONContentThe document, controlled. Pair with onValueChange. The editor skips its own echo, so the value you hand back after a change never resets the selection mid-keystroke.
defaultValueJSONContentThe starting document, uncontrolled. A Tiptap JSON document rather than an HTML string, which is the same shape onValueChange gives back — round-tripping content never means parsing markup.
onValueChange(value: JSONContent, details: RichTextEditorChangeDetails) => voidFires after every document change. details carries html, text, isEmpty, characterCount and wordCount, all read off the editor that just computed them rather than re-derived from the JSON.
placeholderstring"Start writing..."The prompt in an empty document. Drawn in a ::before from the extension's own attribute, so it is never part of the document — it cannot be selected, copied, or counted. Shown only while the editor is empty and editable, because an empty read-only document inviting you to write is a lie about what it does.
maxLengthnumberThe character budget, and a soft one. It never swallows a keystroke — Tiptap's own limit does, which is a silent rejection with nothing announced. Instead the count keeps counting, [data-over-limit="true"] flips, the wording states the overrun, and a polite live region announces the crossing. Setting it also wires the editable region's aria-describedby to RichTextEditor.CharacterCount, so render that part alongside it.
isDisabledbooleanfalseDims the control, stops every command, and empties its tab stops — the editable region loses its tab stop and the buttons take a real disabled attribute.
isReadOnlybooleanfalseStops every command but keeps the region focusable and selectable, so the text can still be read and copied. The region announces as a read-only text box; the role is named explicitly because a non-editable element loses the implicit one that contenteditable gave it.
extensionsExtensionsExtra Tiptap extensions, appended after the ones this component configures. Read once, when the instance is created: extensions define the schema, and a schema cannot change under a live document.
editorOptionsUseEditorOptionsThe escape hatch onto Tiptap's own options, merged over everything this component configures — so it can also override it. Also read once, at creation.
aria-labelstring"Rich text editor"Names the editable region, not the root — the region is the control, so the name belongs on it. Use aria-labelledby instead when a visible heading already names it. There is a fallback rather than nothing, because a rich text region with no name is a defect.
classNamestringClass name for the root.

RichTextEditor.Shell

The painted box holding the toolbar, the content and the footer, and the element the focus ring lands on. Also accepts every native <div> attribute.

PropTypeDefaultDescription
childrenReactNodeThe toolbar, the content and the footer, in whatever order suits the product.
classNamestringClass name for the shell.

RichTextEditor.Toolbar

The button row. Also accepts every prop the @blakeui/react Toolbar takes.

PropTypeDefaultDescription
childrenReactNodeGroups, separators and buttons.
aria-labelstring"Formatting"The toolbar's accessible name. Worth setting per editor when a page holds more than one, so the two are distinguishable.
KeyboardThe whole toolbar is one tab stop. Left and Right move between buttons, Home and End jump to the ends, and Tab leaves the toolbar entirely rather than walking it. The single stop, the arrows and the Tab escape are React Aria's; Home and End are added here, because useToolbar does not bind them.
classNamestringClass name for the toolbar.

RichTextEditor.ToolbarGroup

A layout box for buttons that belong together. Also accepts every native <div> attribute.

PropTypeDefaultDescription
childrenReactNodeThe buttons. The group is presentation only — it carries no role, because an unnamed group is announced as a group with no name. Give it role="group" and an aria-label yourself if a run genuinely needs naming.
classNamestringClass name for the group.

RichTextEditor.ToolbarSeparator

The rule between two runs of buttons. Renders the @blakeui/react Separator, so it also accepts that component's props, and it reads its orientation from the toolbar rather than being told.

PropTypeDefaultDescription
classNamestringClass name for the separator.

RichTextEditor.ToggleButton

A mark or node that turns on and off. Renders the @blakeui/react ToggleButton, so it also accepts that component's props.

PropTypeDefaultDescription
command"bold" | "italic" | "underline" | "strike" | "code" | "blockquote" | "bulletList" | "orderedList" | "codeBlock" | "heading-1" | "heading-2" | "heading-3"Which mark or node this button turns on and off. Required. A built-in name supplies the glyph and the accessible name too, so a default toolbar is one line per button — or pass a spec of your own for anything the table does not carry.
StateOn state reaches assistive tech as aria-pressed, not aria-checked — a formatting control is pressed or not pressed, and that is what makes two states one control instead of two. The button also disables itself whenever the command cannot run, asked through Tiptap's own can(). Both come from a selector subscription: it re-renders when its own active or enabled state changes, not on every transaction.
Custom commandsRichTextEditorToggleCommandSpec{name, attributes?, run, label, icon?} — the schema name isActive is asked about, the attributes that qualify it, how to run it, and what to call it. Everything a built-in gets, a custom one gets, because it runs through the same code: the selector subscription, the can() check that disables the button, aria-pressed, the styling, and its place in the roving focus order. Hoist the spec — one written inline is a new object every render, which rebuilds the button's subscription each time.
tooltipReactNode | falsethe command's label and keybindingWhat the tooltip says. Every button is icon-only, so it gets one by default, and React Aria opens it on keyboard focus as well as hover. The label inside is aria-hidden: React Aria points the trigger's aria-describedby at the tooltip while it is open, so repeating the button's own name would make a keyboard user hear "Bold, button, Bold" — hiding the redundant half leaves the description as the shortcut alone. Pass false to suppress it. A disabled button has none: a disabled <button> fires no pointer events in any browser, so there is nothing for a tooltip to open on.
childrenReactNodethe command's glyphA custom glyph, or any other content — a custom command may carry no icon and label itself with text instead. The accessible name still comes from the command.
classNamestringClass name for the button.

RichTextEditor.ActionButton

A one-shot command. Renders the @blakeui/react Button — deliberately not a toggle, since none of these has an on state, and an aria-pressed on undo would be announcing a state that does not exist.

PropTypeDefaultDescription
action"undo" | "redo" | "indent" | "outdent" | "clearFormatting" | "clearContent"Which command this button runs. Required. indent and outdent are not decoration: Tab was taken away from list nesting to clear the keyboard trap, so the capability has to reappear somewhere a pointer, a switch or a screen reader can reach — a keyboard shortcut on its own would be a narrower replacement than the thing it replaced.
Custom actionsRichTextEditorActionSpec{run, label, icon?, announcement?, isPlain?} — pass one in place of a built-in name for any one-shot command the table does not carry. It picks up the same treatment, including the chip and the live-region announcement. Hoist it, for the same reason a toggle spec is hoisted.
AppearanceMost actions carry a persistent circular chip, which is what says they are a different kind of control from the marks beside them — a mark tints only when it is on, and an action has no on state to tint. indent and outdent are drawn plain instead, because they are structural edits that belong with the list buttons next to them. That is a property of the command, not a prop, so a call site cannot get it wrong.
AnnouncementThe two clears push a message into the same polite live region the character budget uses. They change the document wholesale with nothing on screen moving to explain it, which is exactly the case a reader has to be told about. clearContent is destructive and has no confirmation step, deliberately: undo restores it in one press, and a dialog in front of a one-key undo trains people to dismiss dialogs. Undo and redo announce nothing — they describe themselves.
tooltipReactNode | falsethe command's label and keybindingWhat the tooltip says. Every button is icon-only, so it gets one by default, and React Aria opens it on keyboard focus as well as hover. The label inside is aria-hidden: React Aria points the trigger's aria-describedby at the tooltip while it is open, so repeating the button's own name would make a keyboard user hear "Bold, button, Bold" — hiding the redundant half leaves the description as the shortcut alone. Pass false to suppress it. A disabled button has none: a disabled <button> fires no pointer events in any browser, so there is nothing for a tooltip to open on.
childrenReactNodethe action's glyphA custom glyph. The accessible name still comes from the action.
classNamestringClass name for the button.

RichTextEditor.LinkPopover

The link editor. Renders the @blakeui/react Popover, which is React Aria's DialogTrigger + Popover + Dialog — so the focus scope, Escape-to-close and focus-restore-to-trigger are the library's, and there is no second positioning library anywhere in the component. Its panel is portaled to document.body.

The document selection survives the popover. Focus moving into the URL field takes the DOM selection with it, but ProseMirror does not read the DOM back into state while it is blurred, so state.selection is untouched and Apply lands on exactly the range that was selected. What is lost is only the visual highlight; the fix for that is Tiptap's Selection extension, which needs both a new dependency and its own stylesheet — without the CSS it clears the native highlight and draws nothing, which is strictly worse — so it is deliberately left out.

PropTypeDefaultDescription
childrenReactNodeA Trigger and a Content. Required, and in that order — React Aria reads the first pressable child as the trigger.
Href handlingAn empty field is a no-op, not an empty href — Tiptap would accept href: "" and leave an anchor pointing nowhere. A bare host gains https:// here rather than in Tiptap, whose defaultProtocol only feeds the autolink tokeniser, so setLink would otherwise write example.com through as a relative link.
Scheme safetyjavascript:, data: and vbscript: are rejected, the document is left untouched, and the refusal is announced. The check strips every space-like and control character first — including the byte-order mark, which Tiptap's own strip set misses — so padded and tab-split variants are caught too. It also runs as the extension's isAllowedUri, which puts it on the paste and autolink paths and not only on the button.
KeyboardThe trigger is one stop in the toolbar's roving order, not a new Tab stop — opening the panel does not disturb the arrow-key walk, because React Aria's toolbar ignores events from a portal. Enter in the field applies. Escape closes and returns focus to the trigger.

Parts

PartRendersDescription
LinkPopover.TriggerButtonThe toolbar button that opens the panel. A real button, deliberately not the free Popover.Trigger, which renders a <div role="button"> — a div is the wrong thing inside a role="toolbar", whose focus walk and this component's Home / End handler both look for real buttons. Tints while the selection sits in a link, through a data attribute rather than aria-pressed: this control opens something, and aria-haspopup already says so.
LinkPopover.ContentPopover.Content + Popover.DialogThe floating panel. The inner dialog is what gives the overlay its role and its name — a bare popover div has neither — and is internal rather than a public part. Defaults to placement="bottom start".
LinkPopover.InputTextField + Label + InputThe URL field. A real label, tied to the input by React Aria's generated id / for pair — a placeholder is a hint, never a name, and it disappears exactly when somebody needs it. Takes focus on open, seeded from the link already under the selection so opening on one is an edit. label defaults to "URL".
LinkPopover.ActionsdivThe row the two buttons sit in.
LinkPopover.ApplyButtonButtonApplies the field. Disabled while it is empty, so the no-op is visible before it is one.
LinkPopover.UnsetButtonButtonRemoves the link across its whole range, not just the part under the caret. Disabled when there is no link to remove.

RichTextEditor.Content

The ProseMirror surface, and the accessible control. Also accepts every native <div> attribute.

PropTypeDefaultDescription
childrenTakes none, by design. ProseMirror reconciles this subtree itself, so anything React mounted inside it would be animating or updating nodes replaced out from under it.
SemanticsA multi-line text box: aria-multiline="true", an accessible name from the root's aria-label or aria-labelledby, and aria-describedby pointing at the character count whenever maxLength is set.
classNamestringClass name for the scroll box. The contenteditable inside it is ProseMirror's element and takes its class through editorProps.attributes, not from here.

RichTextEditor.Footer

The strip under the content — where the count sits, and where a submit control would go. Also accepts every native <div> attribute.

PropTypeDefaultDescription
childrenReactNodeWhatever belongs under the document.
classNamestringClass name for the footer.

RichTextEditor.CharacterCount

The budget readout. Subscribes to the counts alone, so it re-renders on a keystroke while the rest of the toolbar does not. Also accepts every native <p> attribute except id, which is fixed because the editable region's aria-describedby points at it.

PropTypeDefaultDescription
children(props: {characters, words, isOverLimit, maxLength}) => ReactNodethe default wordingReplaces the wording entirely, receiving the live counts. The default states the overrun in words as well as flipping [data-over-limit="true"], so a custom child should keep something readable when isOverLimit is true — the colour alone is not the signal.
showWordsbooleanfalseAppends the word count to the default wording. Ignored when children replaces it — a render prop already receives words and can say whatever it likes.
classNamestringClass name for the count.

RichTextEditor.FooterText

The footer's leading slot — a label, a status line, a hint. A real part rather than a bare paragraph at the call site, so it picks up the count's own type scale and muted ink and the two ends of the footer read as one row. Also accepts every native <p> attribute.

PropTypeDefaultDescription
childrenReactNodeThe text. It carries no live-region semantics — anything that has to be announced goes through the editor's announcer, not through here.
classNamestringClass name for the text.

useRichTextEditor

Reads the live Tiptap Editor from the nearest RichTextEditor. null until the instance exists — it is created in an effect, not during render, so the first client render on a server-rendered page has no editor yet.

It does not subscribe to anything: reading editor.state from it gives a value that never updates. Use it to run commands, and reach for useRichTextEditorState to render from state.

useRichTextEditorState

Subscribes to a slice of editor state.

ArgumentTypeDefaultDescription
selector(snapshot: EditorStateSnapshot) => TSelectedPicks the value to render from. The component re-renders only when that value changes, not on every transaction — which is what keeps a toolbar from re-rendering on every keystroke. Backed by useSyncExternalStoreWithSelector, so the comparison is the real thing rather than a convention. snapshot.editor is null for the first render.
equalityFn(a: TSelected, b: TSelected | null) => booleana deep compareHow two selections are compared. The default is deep, so returning a fresh object literal is fine and is the intended shape.

On this page