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 aflexcolumn. Carriesdata-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-borderresolves totransparentin 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--borderinstead, 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— Therole="toolbar"row.display: flexwithflex-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 notrole="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 freeSeparator, which reads its orientation from the context the freeToolbarpublishes, 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 anActionButton. 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 —indentandoutdentare 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 througheditorProps.attributesrather 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 elementaria-describedbypoints at whenmaxLengthis 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.tooltipin@blakeui/stylesand are not re-authored here; only the layout is, because that rule shipsbreak-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.popoverin@blakeui/stylesand are not re-authored here..rich-text-editor__link-input— The URL field'sTextFieldwrapper, 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, becausedisplay: noneandvisibility: hiddenboth 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 setscursor: 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 realaria-pressedtheToggleButtonsets. 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.--dangeris a fill token and fails as small text on the dark page, so this paints a soft pill (--danger-softbehind--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-expandedon the link trigger — React Aria's, fromDialogTrigger, alongside anaria-controlsthat appears only while the panel is open.aria-haspopup="dialog"is written by this component: React Aria sets that attribute formenuandlistboxonly, on the reasoning that screen readers often announce any value as a menu.:focuson the region — the ring lands on.rich-text-editor__shellvia: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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The parts. Required, and in practice always a single RichTextEditor.Shell. |
value | JSONContent | — | The 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. |
defaultValue | JSONContent | — | The 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) => void | — | Fires 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. |
placeholder | string | "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. |
maxLength | number | — | The 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. |
isDisabled | boolean | false | Dims 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. |
isReadOnly | boolean | false | Stops 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. |
extensions | Extensions | — | Extra 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. |
editorOptions | UseEditorOptions | — | The 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-label | string | "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. |
className | string | — | Class 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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The toolbar, the content and the footer, in whatever order suits the product. |
className | string | — | Class name for the shell. |
RichTextEditor.Toolbar
The button row. Also accepts every prop the @blakeui/react Toolbar takes.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Groups, separators and buttons. |
aria-label | string | "Formatting" | The toolbar's accessible name. Worth setting per editor when a page holds more than one, so the two are distinguishable. |
| Keyboard | — | — | The 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. |
className | string | — | Class name for the toolbar. |
RichTextEditor.ToolbarGroup
A layout box for buttons that belong together. Also accepts every native <div> attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The 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. |
className | string | — | Class 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.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Class 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
| State | — | — | On 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 commands | RichTextEditorToggleCommandSpec | — | {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. |
tooltip | ReactNode | false | the command's label and keybinding | What 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. |
children | ReactNode | the command's glyph | A 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. |
className | string | — | Class 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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 actions | RichTextEditorActionSpec | — | {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. |
| Appearance | — | — | Most 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. |
| Announcement | — | — | The 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. |
tooltip | ReactNode | false | the command's label and keybinding | What 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. |
children | ReactNode | the action's glyph | A custom glyph. The accessible name still comes from the action. |
className | string | — | Class 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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | A Trigger and a Content. Required, and in that order — React Aria reads the first pressable child as the trigger. |
| Href handling | — | — | An 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 safety | — | — | javascript:, 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. |
| Keyboard | — | — | The 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
| Part | Renders | Description |
|---|---|---|
LinkPopover.Trigger | Button | The 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.Content | Popover.Content + Popover.Dialog | The 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.Input | TextField + Label + Input | The 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.Actions | div | The row the two buttons sit in. |
LinkPopover.ApplyButton | Button | Applies the field. Disabled while it is empty, so the no-op is visible before it is one. |
LinkPopover.UnsetButton | Button | Removes 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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | — | — | Takes 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. |
| Semantics | — | — | A 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. |
className | string | — | Class 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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Whatever belongs under the document. |
className | string | — | Class 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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | (props: {characters, words, isOverLimit, maxLength}) => ReactNode | the default wording | Replaces 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. |
showWords | boolean | false | Appends the word count to the default wording. Ignored when children replaces it — a render prop already receives words and can say whatever it likes. |
className | string | — | Class 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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The text. It carries no live-region semantics — anything that has to be announced goes through the editor's announcer, not through here. |
className | string | — | Class 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.
| Argument | Type | Default | Description |
|---|---|---|---|
selector | (snapshot: EditorStateSnapshot) => TSelected | — | Picks 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) => boolean | a deep compare | How two selections are compared. The default is deep, so returning a fresh object literal is fine and is the intended shape. |
Radio Button Group
A single-select group whose options are cards, built on a real radio group with roving focus, arrow-key selection, and a per-item indicator.
Chart Chrome
The layers a chart draws around its data — a tinted plot surface, gridlines behind the plot, a shaded region marking a range, and the dashed tail that marks a forecast apart from a measurement.