Rich Text Editor

Pro

A WYSIWYG editor for formatted prose: headings, bold, italic, underline, links and lists. Built on Tiptap for selection, undo and paste, with every pixel of UI owned as a Koala tv recipe.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Activate your Pro license (once per machine)npx koalaui-cli@latest login <key># Add this component (its dependencies come along)npx koalaui-cli@latest add rich-text-editor

Usage#

This example is part of Koala UI Pro

The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.

Have a license? Unlock it here

Anatomy#

import {  RichTextEditor,  RichTextEditorToolbar,  RichTextEditorButton,  RichTextEditorSeparator,  RichTextEditorAlignGroup,  RichTextEditorContent,  RichTextEditorBubbleMenu,} from "@/components/ui/rich-text-editor"<RichTextEditor>  <RichTextEditorToolbar>    <RichTextEditorButton />    <RichTextEditorSeparator />    <RichTextEditorAlignGroup />  </RichTextEditorToolbar>  <RichTextEditorContent />  <RichTextEditorBubbleMenu>    <RichTextEditorButton />  </RichTextEditorBubbleMenu></RichTextEditor>

Placeholder#

An empty editor shows muted ghost text. Set it with the placeholder prop.

People paste links to screenshots rather than upload them. With imageLinks, any paragraph, heading or code block that links to a .png, .jpg, .gif, .webp or .avif shows the picture right under it. It is a decoration, not content: the HTML you save keeps the link alone, and an expired link simply shows nothing.

A complete composer#

Everything together: a seeded document, the full toolbar, the on-selection bubble, a scrollable writing area, and a status bar that lives inside the editor surface: live word/character counts (tabular-nums, so they never reflow) and a save action with transient confirmation. The status bar is just a child that reads the editor via useRichTextEditor.

0 words · 0 characters

Selection bubble menu#

Drop in RichTextEditorBubbleMenu and a floating toolbar surfaces over the current selection. Double-click a word or drag across a phrase and it appears above it (Medium / Notion style). It pairs with the top toolbar or replaces it entirely. Its link control expands inline so the selection-driven bubble never dismisses while you type the URL.

Or go chromeless: omit the top toolbar and let the bubble be the only formatting UI.

Text alignment#

Alignment is opt-in. Pass alignment and paragraphs and headings can be set left, center, right or justified; the default toolbar gains an alignment group, with Tiptap's Mod-Shift-L/E/R/J shortcuts in its tooltips. Pass a list instead of true to offer only some of them. The alignment is written into the HTML as style="text-align: center;", so it survives a save and a reload. Left is the natural flow of a line, so choosing it clears the alignment rather than writing one. Without the prop nothing changes: an editor never keeps an alignment it can't show a control for.

In a toolbar or bubble of your own, place RichTextEditorAlignGroup where you want the controls (size="sm" in a bubble). It is a single-choice toggle group that joins the bar's arrow-key navigation, and it renders nothing while alignment is off.

Labels#

Every string the built-in controls speak or show (the toolbar names, the tooltips that double as aria-labels, and the link editor's field and buttons) comes from labels. Pass the keys you want to change; the rest keep their English defaults. The ghost text stays on placeholder.

Extending the editor#

The document model is open. starterKit reconfigures the built-in StarterKit key by key, over Koala's own setup: a key you set replaces that extension's options, and false drops the extension. Narrow the headings to the levels your HTML uses, keep typed URLs as text, or stop the editor appending an empty paragraph after a closing list (trailingNode: false), so a document saved elsewhere loads and saves back unchanged. extensions registers more Tiptap extensions after the built-in ones: a mark, a node, a plugin or, here, a keyboard shortcut that sends the reply. Their commands reach the live editor through useRichTextEditor. Both are read when the editor mounts, like the rest of its schema.

Send withCtrlEnter

Density#

Like the rest of Koala, the editor honors density. compact tightens the writing area for dense app surfaces. The toolbar sits outside that axis: its 8px padding is what keeps the controls' 12px corners concentric inside the 20px frame, and a format bar that shrank with form density would get hard to hit, so the controls hold 32px either way. Set it per instance or once with a DensityProvider.

Read-only#

Pass editable={false} and omit the toolbar to render stored content as styled prose, useful for previews and feeds.

Custom toolbar#

With no children the toolbar renders the essential controls. For a custom set, pass RichTextEditorButtons and reach the live editor with the useRichTextEditor hook (pair it with Tiptap's useEditorState to reflect active formatting).

Prose scale#

Inside the editor, blocks render on a document scale tuned for reading: the heading steps mirror Tailwind's @tailwindcss/typography prose-sm proportions, scaled to the 14px body (no plugin dependency). Headings run text-3xl → text-sm in the strong foreground, while body copy stays the muted secondary color, so a document reads calm with only its headings and emphasis lifted. These sizes are fixed by the component. For the raw scale they draw from, see the Typography foundation.

H1

text-3xl · 30px

Heading one

H2

text-xl · 20px

Heading two

H3

text-lg · 18px

Heading three

H4

text-sm · 14px

Heading four

Body

text-sm · 14px

Body copy reads in the secondary text color, so only headings and emphasis carry the strong foreground.

API reference#

RichTextEditor

The field surface: creates the Tiptap editor (StarterKit with headings 1 to 4, links and the placeholder) and shares it with every part through context. Forwards native <div> props (except onChange and defaultValue); every part accepts className, merged last.

PropTypeDefaultDescription
defaultValuestring""Initial HTML content. Uncontrolled: the editor owns its document from here on.
onChange(html: string) => void-Called with the serialized HTML on every edit.
placeholderstring"Write something…"Ghost text shown while the document is empty.
editablebooleantrueSet false for a read-only render; toggles without remounting, so the document and selection survive.
density"compact" | "comfortable""compact"Pads the writing area only (compact 12px by 10px, comfortable 16px by 12px); the toolbar holds 32px controls in both. Falls back to the nearest DensityProvider.
alignmentboolean | RichTextEditorAlignment[]falseLets paragraphs and headings be aligned (Tiptap's TextAlign). true offers left, center, right and justify; a list offers only those. Adds an alignment group to the default toolbar. Saved as an inline text-align style; read when the editor mounts.
labelsPartial<RichTextEditorLabels>-Translates or rewords the built-in controls' strings. Missing keys keep the English defaults.
starterKitPartial<StarterKitOptions>-Reconfigures the built-in StarterKit key by key ({ heading: { levels: [1, 2] } }); false drops an extension. Read when the editor mounts.
extensionsExtensions-More Tiptap extensions, registered after the built-in ones. Read when the editor mounts.
imageLinksbooleanfalseShows the picture under any block that links to an image file. Display only: the saved HTML keeps just the link, and a picture that fails to load takes no room. Read when the editor mounts.

RichTextEditorToolbar

The formatting bar, wrapping Radix Toolbar.Root (roving focus, arrow keys) and forwarding its props. With no children it renders the default set: headings 1 to 4, bold, italic, underline, a link popover, and bullet and numbered lists.

PropTypeDefaultDescription
childrenReact.ReactNode-A custom set of RichTextEditorButtons and RichTextEditorSeparators, replacing the default controls. Its buttons share one gliding tooltip.
orientation"horizontal" | "vertical""horizontal"Radix arrow-key axis for roving focus.
loopbooleantrueWhether keyboard focus wraps from the last control to the first.
Data attributeDescription
data-orientationSet by Radix: "horizontal" or "vertical".

RichTextEditorButton

An icon-only control wrapping Radix Toolbar.Button inside a Tooltip, and forwarding its props. Wire it to the editor with onClick and pressed, reading state through useRichTextEditor.

PropTypeDefaultDescription
tooltip*string-The accessible label and hover tooltip.
pressedboolean-Marks the control active (e.g. the caret sits in bold text) and sets aria-pressed.
shortcutstring-A keycap shown in the tooltip, e.g. "⌘B". Display only: it does not bind the key.
size"sm" | "md""md"md is a 32px square for the toolbar; sm is 36 by 28px for the bubble menu.
staticbooleanfalseKept for compatibility; controls no longer scale on press.
disabledboolean-Disables the control and skips it in roving focus.
Data attributeDescription
data-pressedPresent when pressed.

RichTextEditorSeparator

A vertical hairline that groups related controls. Wraps Radix Toolbar.Separator; forwards its props.

Data attributeDescription
data-orientationSet by Radix, perpendicular to the toolbar: "vertical" in a horizontal toolbar.

RichTextEditorAlignGroup

The text-alignment controls: one toggle per alignment the editor's alignment prop offers, as a single-choice Radix Toolbar.ToggleGroup in the bar's roving focus. The default toolbar places it; drop it into a custom toolbar or a bubble yourself. Choosing left clears the alignment rather than writing text-align: left. Renders nothing while alignment is off.

PropTypeDefaultDescription
size"sm" | "md""md"md is a 32px square for the toolbar; sm is 36 by 28px for the bubble menu.
staticbooleanfalseKept for compatibility; controls no longer scale on press.
classNamestring-Merged onto the group.
Data attributeDescription
data-pressedPresent on the toggle of the current alignment (left while the block is unaligned).

RichTextEditorContent

The writing area: Tiptap's EditorContent with Koala's prose styling (muted body, foreground headings, underlined links). Forwards its props except editor, which comes from context.

RichTextEditorBubbleMenu

A floating toolbar over the current selection, built on Tiptap's BubbleMenu (Floating UI). className styles the inner toolbar surface; other props go to the floating wrapper. Renders nothing until the editor mounts.

PropTypeDefaultDescription
childrenReact.ReactNode-Custom controls (use size="sm" buttons). Without children it renders bold, italic, underline and a link control that expands into an inline URL field.
optionsBubbleMenuPluginProps["options"]{ placement: "top", offset: 8 }Floating UI options (placement, offset, flip, shift...), merged over the defaults.
shouldShow(props) => boolean-Tiptap callback deciding whether the menu shows for the current selection.
updateDelaynumber250Milliseconds to wait before repositioning after a selection change.
Data attributeDescription
data-orientationSet by Radix on the inner toolbar: "horizontal".

useRichTextEditor

A hook returning the live Tiptap Editor from the nearest RichTextEditor, for custom toolbars, word counts or imperative reads and writes. Returns null on the first client render (the editor mounts after, to avoid a hydration mismatch).

FAQ#

Rich text needs ProseMirror's document model for selection, undo, and paste, which no Radix primitive provides. Like Tooltip wrapping Tippy.js, it is the considered exception to Koala's Radix-first rule, with every pixel of UI still owned as a tv recipe.