packages/commenting/REACTIONS.md
Comment reactions store a token — an opaque string the commenting layer only ever stores, syncs, and hands back to a renderer. It never assumes the token is an emoji glyph. Out of the box the token is the glyph (drawn by the OS emoji font), but you can swap the palette to make a reaction be anything: custom emoji images, SVGs, drawings, a fixed brand set, etc.
Every extension point below — the palette, the renderer, the validator, and the reactor tooltip — is opt-in. Each is a slot on the comment tool that defaults to a built-in when you leave it unset. So an unconfigured tool gives you the full standard experience: OS-font emoji reactions, the default emoji picker, and the default hover tooltip.
// All of these are equivalent — the built-in emoji reactions + default tooltip:
const commentTools = [CommentTool]
const commentTools = [CommentTool.configure({})]
const commentTools = [CommentTool.configure({ components: {} })]
You only pass an override to change that specific slot; everything you don't pass stays the built-in.
configure({}) is not "turn reactions off" — it's "use every default." Reverting any customisation
is just deleting the override, never adding config back.
A reaction is one record per (comment, user, token) (see TLCommentReaction in @tldraw/tlschema).
The token is stored verbatim. Three things decide how a palette behaves, and they must agree —
they're facets of one palette:
| Concern | Extension point | Default |
|---|---|---|
| What tokens exist / how they're picked | components.ReactionPalette | EmojiPicker (a grid of DEFAULT_REACTION_EMOJI) |
| How a token is drawn | components.ReactionContent | the token string (OS emoji font) |
| Which tokens may be written | isAllowedReaction(token) | isAllowedReactionEmoji (membership in DEFAULT_REACTION_EMOJI) |
All three are set on the comment tool:
CommentTool.configure({
components: {
ReactionPalette: MyPalette,
ReactionContent: MyReactionContent,
},
isAllowedReaction: (token) => /* is this one of mine? */,
})
If you set none of them, you get the default emoji palette. Reverting a custom palette is just deleting these three overrides — the defaults are the emoji system.
Why three, and why they must agree: the picker emits tokens, the renderer draws them, and the validator gates writes. If the picker emits a token the renderer doesn't understand, the pill shows the raw token string; if the validator rejects it, the write is silently dropped. Keep the three in lockstep. (This is a known rough edge — the intent is to collapse them into a single
reactionPaletteobject; until then, set all three together.)
components.ReactionPaletteA component with the same props as the built-in EmojiPicker (EmojiPickerProps):
interface EmojiPickerProps {
emoji?: string[] // tokens to offer (your palette)
selected?: string[] // tokens the user has already reacted with (show as pressed)
onSelect?(token: string): void // call this with the chosen token
renderReaction?: RenderReaction // draw a token (pass through to your renderer)
}
It renders inside the add-reaction dropdown. When the user picks something, call
onSelect(token) — that token is what gets stored. It can be anything: an emoji glyph, a
shortcode, a data: URL, an id into your own store.
components.ReactionContentA component ({ token }) => ReactNode used to draw a token wherever a reaction appears (the count
pills, and — via renderReaction — inside the palette). Return an ``, an SVG, or plain text:
function MyReactionContent({ token }: { token: string }) {
return isMyToken(token) ? : <>{token}</>
}
Fall through to <>{token}</> for tokens you don't own, so a custom palette and the stock emoji
palette can coexist on the same comment.
The presentational components (Reaction, Reactions, EmojiPicker, ReactionPicker) also take a
renderReaction(token): ReactNode function prop directly, if you're composing them yourself
rather than through CanvasComments. defaultRenderReaction is the token-string default.
isAllowedReaction(token)Gates which tokens may be added (removals always go through, so a reaction carrying an off-palette token can still be cleared). This is the client-side guard that stops a token the picker would never offer. It should accept exactly the tokens your palette emits:
isAllowedReaction: (token) => isMyToken(token) || isAllowedReactionEmoji(token)
Server-side, a reaction is still verified for identity/ownership by the sync authorizer, but the palette membership check is client-side. If you need a hard server bound, enforce your token set in the sync server too.
The drawn reactions example implements a complete custom palette — a small tldraw canvas
you draw in, stored as a data: image token. It lives in the examples app rather than in this
package, since it's one shape a custom palette can take rather than something the SDK has an
opinion about. Wiring it is the three overrides:
import { CommentTool, isAllowedReactionEmoji, type EmojiPickerProps } from '@tldraw/commenting'
import {
DrawingReactionContent,
DrawingReactionPalette,
isDrawingReactionToken,
} from './drawing-reactions'
// DrawingReactionPalette is a nested tldraw editor, so bind its license key here.
function ReactionPalette(props: EmojiPickerProps) {
return <DrawingReactionPalette {...props} licenseKey={getLicenseKey()} />
}
const commentTools = [
CommentTool.configure({
components: {
ReactionPalette,
ReactionContent: DrawingReactionContent, // draws data: tokens as , emoji fall through
},
// drawings OR emoji are valid, so old emoji reactions still work after the switch
isAllowedReaction: (token) => isDrawingReactionToken(token) || isAllowedReactionEmoji(token),
}),
]
To go back to plain emoji, delete those overrides:
const commentTools = [CommentTool.configure({})]
That's the entire revert — no package changes, because the defaults are the emoji palette.
Separate from the palette (which is about the tokens themselves), the hover tooltip that names who
reacted is its own override. By default, hovering a reaction pill shows an inline sentence —
You reacted, You and Bo reacted, You, Bo and Ada reacted, then You, Bo, Ada and N others reacted past three names.
There are two levels of customisation, smallest first:
Reword it — translation, no code. The sentence is built from the comments.reacted-1,
comments.reacted-2, comments.reacted-3, comments.reacted-more, and comments.reacted-more-one
strings (plus comments.mention-you for "You"). Override them through the standard tldraw
translation overrides to change the words or reorder the {a} / {b} / {c} / {count}
placeholders per locale. This is also how the grammar gets localised.
Replace it — components.ReactionTooltip. To change more than the wording, replace the whole
affordance. The component receives the reactors and the pill itself (as children) and
returns the entire thing — so it owns the tooltip, its box, size, shape, and position, not just
the text inside. That's what lets it be anything: a different box, avatars, or a banner painted
anywhere on screen.
The default just hangs the built-in sentence in a standard tooltip. To keep that box but change
the contents, wrap children in TldrawUiTooltip yourself:
import { CommentTool, type ReactionTooltipProps } from '@tldraw/commenting'
import { TldrawUiTooltip } from 'tldraw'
function AvatarReactionTooltip({ reactors, children }: ReactionTooltipProps) {
return (
<TldrawUiTooltip
side="bottom"
content={reactors.map((r, i) => (
<Avatar key={i} name={r.you ? 'You' : r.name} />
))}
>
{children}
</TldrawUiTooltip>
)
}
const commentTools = [
CommentTool.configure({ components: { ReactionTooltip: AvatarReactionTooltip } }),
]
To break out of the tooltip entirely — your own popover, a fixed banner, no box at all — don't
render TldrawUiTooltip; wrap children however you like and own the hover state yourself.
ReactionTooltipProps gives you reactors: { name: string; you: boolean }[] in reaction order,
plus children (the pill). Unset, it falls back to DefaultReactionTooltip (the default wrapper).
Two exports help you compose: DefaultReactionTooltip (the wrapper) and
DefaultReactionTooltipContent (just the inline sentence, to drop inside your own box). This is
the same override mechanism as ReactionContent / ReactionPalette, just for the reactor list.
enableHoverListstill applies upstream: when another menu on the comment is open the pill renders bare and yourReactionTooltipisn't mounted, so you never have to handle that case.
id = f(comment, user, token)), so keep tokens small.
Large tokens (e.g. an inlined drawing) bloat the id and the Postgres primary key. The drawn
reactions example caps token length for this reason.data: prefix allowlist.