Rich text
Rich text lets you add formatted text to tldraw shapes. You get inline formatting like bold, italic, code, and highlighting, plus structural features like lists and links. Text, note, geo, and arrow label shapes all support rich text editing.
Under the hood, tldraw uses TipTap (a React wrapper around ProseMirror) as the rich text engine. TipTap stores text as structured JSON rather than plain strings, which enables reliable formatting operations, custom extensions, and consistent serialization.
How it works
Rich text content is represented as a JSON tree. The root document contains paragraphs, and paragraphs contain text nodes with optional formatting marks. This structure aligns with TipTap's document model and makes it easy to extend.
Document structure
A rich text document has three main components: the document root, content blocks, and text nodes with marks.
const richText: TLRichText = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hello ' },
{
type: 'text',
text: 'world',
marks: [{ type: 'bold' }],
},
],
},
],
}The type field identifies the node kind. The content array holds child nodes. Text nodes include a marks array for formatting information. You can combine formatting marks in any way you need.
Converting between formats
Use toRichText to convert plain text strings to rich text documents. Each line becomes a separate paragraph:
import { toRichText } from 'tldraw'
const richText = toRichText('First line\nSecond line')
// Creates two paragraphsNote that toRichText treats all input as plain text—it doesn't parse markdown or other formatting. To create formatted content programmatically, build the rich text JSON structure directly or use the TipTap editor API.
To extract plain text from a rich text document, use renderPlaintextFromRichText. It strips all formatting and preserves line breaks:
import { renderPlaintextFromRichText } from 'tldraw'
const text = renderPlaintextFromRichText(editor, shape.props.richText)
// Returns: "First line\nSecond line"For HTML output, use renderHtmlFromRichText. It preserves all styling and structure, which is useful for rendering rich text outside the editor or exporting content:
import { renderHtmlFromRichText } from 'tldraw'
const html = renderHtmlFromRichText(editor, shape.props.richText)
// Returns: '<p dir="auto">First line</p><p dir="auto">Second line</p>'TipTap integration
TipTap handles the rich text editing experience. The editor appears when users double-click text shapes or press Enter while a text shape is selected. It manages focus, keyboard shortcuts, and formatting commands automatically.
Default extensions
The tipTapDefaultExtensions array includes TipTap's StarterKit plus customizations for tldraw. The StarterKit provides basic formatting like bold, italic, and lists. Additional extensions add code highlighting and custom keyboard behavior.
export const tipTapDefaultExtensions: Extensions = [
StarterKit.configure({
blockquote: false,
codeBlock: false,
horizontalRule: false,
link: {
openOnClick: false,
autolink: true,
},
}),
Highlight,
KeyboardShiftEnterTweakExtension,
extensions.TextDirection.configure({ direction: 'auto' }),
]This configuration disables blockquotes, code blocks, and horizontal rules to keep the interface focused on inline formatting. Links don't open on click during editing, which prevents accidental navigation. Text direction is set to automatic for right-to-left language support.
Custom extensions
You can add custom TipTap extensions through the textOptions prop on the Tldraw component. This lets you add new formatting options, custom keyboard shortcuts, or specialized behavior:
import { Mark, mergeAttributes } from '@tiptap/core'
import { StarterKit } from '@tiptap/starter-kit'
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
const CustomMark = Mark.create({
name: 'custom',
parseHTML() {
return [{ tag: 'span.custom' }]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes({ class: 'custom' }, HTMLAttributes), 0]
},
addCommands() {
return {
toggleCustom:
() =>
({ commands }) =>
commands.toggleMark(this.name),
}
},
})
const textOptions = {
tipTapConfig: {
extensions: [StarterKit, CustomMark],
},
}
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw textOptions={textOptions} />
</div>
)
}Note that you must provide a complete list of extensions. If you include custom extensions, also include any default extensions you want to keep. The example above replaces the entire extension list, giving you full control over what features are available.
Rich text toolbar
The rich text toolbar appears when editing text shapes. It gives you quick access to formatting commands like bold, italic, and lists. The toolbar updates dynamically to show which formats are active at the current cursor position.
You can customize the toolbar by overriding the RichTextToolbar component. Use editor.getRichTextEditor() to get the TipTap editor instance and execute formatting commands:
import {
DefaultRichTextToolbar,
TLComponents,
Tldraw,
TldrawUiButton,
preventDefault,
useEditor,
useValue,
} from 'tldraw'
import 'tldraw/tldraw.css'
const components: TLComponents = {
RichTextToolbar: () => {
const editor = useEditor()
const textEditor = useValue('textEditor', () => editor.getRichTextEditor(), [editor])
return (
<DefaultRichTextToolbar>
<TldrawUiButton
type="icon"
onClick={() => textEditor?.chain().focus().toggleBold().run()}
onPointerDown={preventDefault}
>
B
</TldrawUiButton>
</DefaultRichTextToolbar>
)
},
}
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw components={components} />
</div>
)
}The DefaultRichTextToolbar component provides the default toolbar layout. Nest custom buttons inside it to extend the toolbar, or replace it entirely for complete control over the formatting interface.
Shapes with rich text
Four shape types support rich text: text shapes, note shapes, geo shapes, and arrow labels. Each renders rich text through the RichTextLabel component, which handles both display and editing modes.
Text shapes
Text shapes are standalone text blocks you can place anywhere on the canvas. They support auto-sizing (where the shape grows to fit content) or fixed-width mode with text wrapping.
editor.createShape({
type: 'text',
x: 100,
y: 100,
props: {
richText: toRichText('Sample text'),
font: 'draw',
size: 'm',
textAlign: 'start',
autoSize: true,
},
})The autoSize prop controls whether the shape expands automatically. When true, text never wraps and the shape width matches the content. When false, text wraps at the shape's width boundary.
Note shapes
Note shapes display text on colored backgrounds. They always have fixed dimensions and wrap text to fit within those bounds.
editor.createShape({
type: 'note',
x: 100,
y: 100,
props: {
richText: toRichText('Note content'),
font: 'draw',
size: 'm',
color: 'yellow',
},
})Notes work well for annotations, comments, or highlighting specific information on the canvas. The colored background provides visual distinction from regular text shapes.
Geo shapes
Geo shapes include rectangles, ellipses, and other geometric forms that can contain text labels. Text appears centered within the shape bounds, with configurable alignment.
editor.createShape({
type: 'geo',
x: 100,
y: 100,
props: {
geo: 'rectangle',
w: 200,
h: 100,
richText: toRichText('Label'),
font: 'draw',
size: 'm',
align: 'middle',
verticalAlign: 'middle',
},
})The align and verticalAlign props control text positioning within the shape. Text wraps when it exceeds the available width minus padding.
Arrow labels
Arrows can have a text label that appears along the arrow path. The label is editable like other rich text and moves with the arrow when repositioned.
editor.createShape({
type: 'arrow',
x: 100,
y: 100,
props: {
start: { x: 0, y: 0 },
end: { x: 200, y: 0 },
richText: toRichText('Arrow label'),
font: 'draw',
size: 'm',
},
})Arrow labels automatically position themselves based on the arrow's path and curvature. The label remains readable regardless of arrow orientation.
Font management
Rich text can include multiple fonts and font styles within a single text block. The FontManager tracks which fonts are needed and ensures they load before rendering, preventing layout shifts.
Use getFontsFromRichText to collect all required font faces based on the text content and formatting marks:
import { getFontsFromRichText } from 'tldraw'
const fonts = getFontsFromRichText(editor, richText, {
family: 'tldraw_draw',
weight: 'normal',
style: 'normal',
})The function accepts an initial font state representing the base font. It walks the document tree, examining marks on text nodes to determine when bold or italic variants are needed. When code marks are present, it switches to the monospace font family.
Custom font resolution
You can override font resolution by providing a custom addFontsFromNode function through textOptions. This lets you use custom fonts or alternative font mapping strategies:
const textOptions = {
tipTapConfig: {
extensions: [StarterKit],
},
addFontsFromNode: (node, state, addFont) => {
// Custom font resolution logic
if (node.marks.some((m) => m.type.name === 'bold')) {
state = { ...state, weight: 'bold' }
}
// Call addFont() with required font faces
return state
},
}The function receives the current node, font state, and a callback to register required fonts. It returns the updated state for child node processing, letting you walk the document tree while maintaining font context.
Programmatic formatting
You can apply formatting to rich text programmatically by accessing the TipTap editor instance. This enables bulk operations like applying formatting to multiple shapes or implementing custom formatting commands:
const textEditor = editor.getRichTextEditor()
if (textEditor) {
// Make all selected text bold
textEditor.chain().focus().selectAll().toggleBold().run()
}The chain API lets you compose multiple operations. Each command returns a chainable object, and run() executes the composed command sequence.
For operations outside the editing context, you can manipulate the rich text JSON directly. This is useful when programmatically generating or transforming content:
function makeAllTextBold(richText: TLRichText): TLRichText {
const content = richText.content.map((paragraph) => {
if (!paragraph.content) return paragraph
return {
...paragraph,
content: paragraph.content.map((node) => {
if (node.type !== 'text') return node
const marks = node.marks || []
if (marks.some((m) => m.type === 'bold')) return node
return {
...node,
marks: [...marks, { type: 'bold' }],
}
}),
}
})
return { ...richText, content }
}This approach requires understanding the TipTap document structure but gives you precise control over content transformation.
Measurement and rendering
Rich text measurement uses the same system as plain text, with HTML rendering replacing plain text content. The TextManager measures rich text by generating HTML, applying it to the measurement element, and reading the computed dimensions:
import { renderHtmlFromRichTextForMeasurement } from 'tldraw'
const html = renderHtmlFromRichTextForMeasurement(editor, richText)
// Returns HTML wrapped in measurement containerThe measurement system accounts for formatting that affects layout, like bold text or lists. Font loading completes before measurement to ensure accurate dimensions.
For SVG export, the RichTextSVG component renders rich text as a foreignObject element, preserving formatting and layout in exported images.
Extension points
The rich text system offers several ways to customize behavior:
| Extension point | Description |
|---|---|
| Custom TipTap extensions | Add new marks, nodes, keyboard shortcuts, or commands |
| Custom toolbar | Replace or extend the rich text toolbar with different formatting controls |
| Font resolution | Override font resolution to use custom fonts or alternative loading strategies |
The textOptions prop accepts a tipTapConfig object that passes through to TipTap's editor configuration, giving you access to all TipTap configuration options:
const textOptions = {
tipTapConfig: {
extensions: [...],
editorProps: {
attributes: {
class: 'custom-editor',
},
},
},
addFontsFromNode: customFontResolver,
}Related examples
-
Rich text with custom extension - Adding a custom TipTap extension and toolbar button.
-
Rich text with font extensions - Extending the editor with font-family and font-size controls.
-
Format rich text on multiple shapes - Applying formatting to multiple selected shapes programmatically.