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 paragraphs

Note 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 container

The 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 pointDescription
Custom TipTap extensionsAdd new marks, nodes, keyboard shortcuts, or commands
Custom toolbarReplace or extend the rich text toolbar with different formatting controls
Font resolutionOverride 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,
}
Prev
Readonly mode
Next
Scribble