Commenting

In tldraw, a comment is a message pinned to a place on the canvas. Comments group into threads, one conversation per pin. Every comment records who wrote it, as an id your app resolves to a name.

The @tldraw/commenting package works at two levels. CanvasComments is a comments layer you render in front of the canvas, and it handles the whole flow. Everything it's built from is exported too, so you can replace any part of it.

Commenting is a licensed feature. It runs in development without a key. In production it needs a tldraw license that includes commenting.

Quick start

Three pieces: register the comment record types, register the comment tool, and render the layer.

import {
	CanvasComments,
	CommentAuthor,
	commentToolOverrides,
	commentTools,
} from '@tldraw/commenting'
import { useMemo } from 'react'
import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
import '@tldraw/commenting/commenting.css'
import 'tldraw/tldraw.css'

const AUTHORS: Record<string, CommentAuthor> = { me: { name: 'You', color: '#EC5E41' } }
const resolveAuthor = (id: string) => AUTHORS[id]

const components: TLComponents = {
	InFrontOfTheCanvas: () => <CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />,
}

export default function App() {
	const store = useMemo(
		() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
		[]
	)

	return (
		<div style={{ position: 'fixed', inset: 0 }}>
			<Tldraw
				licenseKey={YOUR_LICENSE_KEY}
				store={store}
				tools={commentTools}
				overrides={[commentToolOverrides]}
				components={components}
			/>
		</div>
	)
}

Pick the comment tool from Quick Actions, or press C, then click the canvas to start a thread.

The layer's two required inputs are both about identity: currentUserId is the id stamped on whatever the user posts, and resolveAuthor turns an author id into a name, a color, and an optional avatar image. Commenting reads no user directory of its own, so these are where you connect it to whatever your app already knows about its users. With the read-status and mention callbacks they make up the CommentingContext, which CanvasCommentsSidebar takes too — build it once and spread it into both.

Comments are records

Comment threads and comments are records in the editor's store, exactly like shapes. That's the fact most worth holding onto, because almost everything else follows from it.

They aren't in the default schema, so you opt in by registering commentSchemaRecords. Once you have, comments persist and sync however your document already does. Adding a persistenceKey or a sync backend carries them along with no extra work.

It also means you can read and write them yourself. Query the store for threads, seed a document with review notes, or build a panel that does something the built-in sidebar doesn't.

import { useCommentThreads } from '@tldraw/commenting'
import { useEditor } from 'tldraw'

function OpenThreadCount() {
	const editor = useEditor()
	const threads = useCommentThreads(editor)
	return <div>{threads.filter((thread) => !thread.resolved).length} open threads</div>
}

Anchors

What pins a thread to the canvas is its anchor. A thread can anchor to a point on the page, to a shape it then follows as that shape moves and resizes, to a rectangular region, or to the page as a whole.

Clicking empty canvas gives you a point anchor and clicking a shape gives you a shape anchor. Region anchors are off by default; turn them on and dragging the tool out covers an area.

Shape-anchored threads outlive their shape. Delete the shape and the thread converts to a point anchor where its pin last sat, so the conversation doesn't disappear along with the thing it was about.

What you get

The comments layer covers the whole flow out of the box:

FeatureDescription
ThreadsPins on the canvas, opening to replies, edit, resolve, and delete.
Mentions@-mentions in composers, resolved against a roster you supply.
ReactionsEmoji reactions on comments, with a pluggable palette.
A sidebarA filterable list of threads beside the canvas.
ClusteringNearby pins fold into count badges as you zoom out.
Unread statePin badges and an unread filter, driven by your app's read data.

Each of these has options, and each visible piece is a component slot you can replace. See Commenting for the full guide.

Configuring

Commenting options live on the tool. CommentTool.configure() returns a configured subclass to register, mirroring ShapeUtil.configure:

const tools = [CommentTool.configure({ enableRegions: true })]

The option worth knowing about first is history. Comment writes default to 'ignore', which keeps them off the undo stack, and that default matters in a shared document: an undoable delete would resurrect a thread a collaborator already removed. See Comments and undo.

Permissions

canComment decides whether the viewer may participate, and the UI follows it: composers give way to a fallback slot and the action affordances hide. canModifyComment decides the writes that belong to someone in particular — editing a comment, deleting a comment, deleting a thread. Unset, each is its record's owner's to make; a callback widens that (a workspace admin who may remove anyone's comment) or narrows it.

They're UI-level controls and nothing more. Comment records carry a client-supplied author id, so rules about who may post, edit, or delete belong in your sync server's record authorization, where each incoming record is checked against the session's identity.

Syncing

Comments sync like the rest of your document, with one registration on each side. Pass records: commentSchemaRecords to your sync hook, and the same map to createTLSchema on the server.

On the server you can go a step further and serve comments through the room's object-store lane. Lane records are gated by their own per-session permission rather than by isReadonly, which is how a viewer who can't edit the document can still comment. See Syncing comments.

Prev
AI integrations
Next
tldraw sync