Commenting end to end — identity, the on-page avatar pin, the thread, where it landed in review, and the card that opens over a pin.
"use client";
import { createPluginRegistration } from "@embedpdf/core";About#
Commenting is one feature made of a few pieces, and they ship as one component because none of them stands up alone:
<PdfCommentUserProvider>— who is writing, and how any author is displayed.<PdfCommentPin />— the on-page marker, an avatar teardrop in place of the plugin's default sticky-note icon.<PdfCommentThread />— a root comment, its replies, and a reply box.<PdfCommentPopover />— the card that opens over a selected pin, holding the composer or the thread.<PdfCommentThreadButton />— the affordance for commenting on something that isn't a comment: a highlight, a square, an ink stroke.<PdfCommentStatus />— where a thread landed: accepted, rejected, cancelled, completed.
Arm the tool, click the page, and a composer opens right where you dropped the pin. Send it and the card becomes the thread — read, reply, edit, delete, without leaving the page.
The comments live in the PDF itself. Every message is a Text annotation — the
root pin, and each reply linked to it by inReplyToId — so a thread survives
export and reopens with the document, the same data the sidebar or another
reader would show.
Everything here reads annotation state and renders it; nothing overrides what
the annotation plugin does. To compose on click instead of writing an empty pin
the moment it lands, add
<PdfCommentDraftLayer /> — it ships
separately because it changes plugin behaviour rather than dressing it.
Annotations and comments#
A comment is a kind of annotation, but pdfviewcn keeps the two apart, and the whole component is shaped around the split:
- An annotation is artwork. A highlight, a square, an ink stroke. It's named after the tool that drew it and it lives in the annotation sidebar.
- A comment is a conversation. Somebody wrote it, it's timestamped, it can be replied to, and its whole reason to exist is the words in it. It lives in the comment sidebar.
isPdfCommentPin draws that line: a Text annotation that isn't itself a reply.
Everything else is artwork, no matter what note happens to be attached to it.
The alternative is Acrobat's model, where every markup annotation is a comment and one panel lists all of them — which is why drawing a rectangle there puts an empty row in the comments list. Nutrient, Figma, and Google Docs all draw the line pdfviewcn draws.
Commenting on an existing annotation replies to it rather than writing into
its contents. A reply carries an author, a timestamp, and a thread; a
contents write is one unattributed string that silently overwrites whatever
another tool put there. See Commenting on an
annotation.
None of this renders until AnnotationPluginPackage is registered on
<PdfViewer /> and a <PdfAnnotationLayer>
is rendering pages. The pin and the popover are both wired through that
layer's render props — they aren't standalone controls.
Review status#
A thread can be ruled on as well as read: Accepted, Rejected, Cancelled, Completed, or back to None. That's PDF's own Review state model, not a vocabulary pdfviewcn invented, so a status set here is the one Acrobat shows under Set Status.
It's stored the way the format asks for, and the status isn't a field on the annotation:
Beginning with PDF 1.5, annotations may have an author-specific state associated with them. The state is not specified in the annotation itself but in a separate text annotation that refers to the original annotation by means of its IRT ("in reply to") entry.
— ISO 32000-2, 12.5.6.3
So marking a thread resolved writes a reply — authored, dated, and wordless —
carrying /State and /StateModel. Two consequences worth knowing:
- Status is per author. Two reviewers hold two of them, and disagreement is
preserved in the file.
<PdfCommentStatus>updates yours in place instead of appending a second one; a list row shows the thread's latest from anybody, because that's all a row has space for. - Wordless replies aren't messages.
getPdfCommentMessagesfilters them out, so resolving a thread doesn't post an empty bubble or bump the reply count.
Only Text annotations get a state in PDF, so this belongs to threads rather than to the annotation inspector — a square is marked resolved by resolving the conversation on it.
Identity#
A PDF comment stores only a plain author name on the pin — there's nowhere to
put an avatar or a colour — so everything richer is resolved from that name at
render time. <PdfCommentUserProvider> answers the two questions the pin,
popover, and sidebar all ask:
- Who is writing? —
currentUseris the person authoring comments and replies from this viewer. Theirnameis written to each new pin and reply. - How should an author look? —
resolveUserturns a stored name into a display identity: a canonical name, an avatar URL, an accent colour. It's called for every message author, so a directory lookup lights up everyone's avatar at once.
It's entirely optional. With no provider, the current user is "You" and every
author shows initials on a colour derived deterministically from their name — so
the same person keeps the same colour across sessions with no stored state. Add
the provider once you know who's signed in.
Companion components#
Three commenting surfaces stay separate, because each is a choice you make rather than something you inherit:
<PdfCommentButton />arms the built-intextCommenttool so the next click drops a pin.<PdfCommentSidebar />is a newest-first index of every thread; clicking a row selects its annotation and scrolls to it.<PdfCommentDraftLayer />makes a click compose a comment instead of creating one, so nothing enters the document until it's posted.
And one that isn't a choice:
<PdfCommentLayer /> is the annotation
layer with the pin, the popover, and the draft layer already wired into it. It's
what you mount on any surface that can make a comment; Usage below is
what it does for you.
Installation#
pnpm dlx shadcn@latest add @pdfviewcn/comment
Usage#
Everything below is what
<PdfCommentLayer /> already does — mount
that and skip to Examples. Read on when you're assembling the
surface yourself, or replacing a piece of it.
Register the annotation plugin with an author.
import { createPluginRegistration } from "@embedpdf/core";
import { AnnotationPluginPackage } from "@embedpdf/plugin-annotation/react";
// Outside the component — a new array identity on every render tears the
// engine down and rebuilds it, losing scroll and zoom state. `annotationAuthor`
// attributes each pin and reply to a name the thread shows.
const plugins = [
createPluginRegistration(AnnotationPluginPackage, {
annotationAuthor: "You",
}),
];Render the annotation layer, routing comment pins to the marker and to the thread card.
<PdfViewer
documents={[{ url: "/sample.pdf" }]}
plugins={plugins}
className="h-[720px]"
>
<PdfToolbar>
<PdfCommentButton />
</PdfToolbar>
<PdfViewerContent
pageLayers={({ documentId, pageIndex }) => (
<PdfAnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
customAnnotationRenderer={({
annotation,
children,
isSelected,
onSelect,
}) =>
isPdfCommentPin(annotation) ? (
<PdfCommentPin
annotation={annotation}
isSelected={isSelected}
onSelect={onSelect}
/>
) : (
children
)
}
selectionMenu={(props) =>
isPdfCommentPin(props.context.annotation.object) ? (
<PdfCommentPopover documentId={documentId} {...props} />
) : null
}
/>
)}
/>
</PdfViewer>The layer renders every annotation through customAnnotationRenderer, so
route comment pins to <PdfCommentPin /> and return children — the plugin's
default rendering — for everything else. Do the same in selectionMenu, handing
non-comment types to
<PdfAnnotationSelectionMenu />
if you render other annotation kinds.
As written, a click with the comment tool armed creates the pin immediately —
that's the annotation plugin's own behaviour. Add
<PdfCommentDraftLayer /> beside the
annotation layer to compose first and create on post instead. That's the third
piece, and once all three are present you've rebuilt
<PdfCommentLayer /> by hand.
Use isPdfCommentPin rather than a bare type === PdfAnnotationSubtype.TEXT
check: replies are Text annotations too, and they belong in the thread, not on
the page as a second pin on top of the one they answer.
The documentId isn't part of either render-prop payload, so pass it from the
pageLayers scope where you already have it. Everything else — selected,
context, menuWrapperProps, rect — is spread straight through from the
layer.
The selection box#
A selected pin gets no rectangular outline. Its box is 24px square and the pin
fills it, so the outline would land as four corners poking out from behind a
round marker, and the pin already draws its own accent ring.
<PdfAnnotationLayer>
drops it for you; every other type keeps its box.
Under a bare <AnnotationLayer> from the plugin, ask for it yourself — but
selectionOutline is layer-wide, so this drops the box for every other
annotation type on the same layer too:
<AnnotationLayer
documentId={documentId}
pageIndex={pageIndex}
selectionOutline={{ width: 0 }}
// …
/>Setting the author#
Wrap the viewer once you know who's signed in, and match currentUser.name to
the annotation plugin's annotationAuthor so a person's pins and replies read
as the same author. Pass resolveUser to light up avatars from your own
directory:
<PdfCommentUserProvider
currentUser={{ name: "Ada Lovelace", avatar: "/avatars/ada.png" }}
resolveUser={(name) => directory[name]}
>
<PdfViewer documents={[{ url: "/sample.pdf" }]} plugins={plugins}>
{/* comment button, layer, sidebar… */}
</PdfViewer>
</PdfCommentUserProvider>resolveUser may return a partial identity — anything you leave out is filled
in from the defaults, so returning just { avatar } keeps the derived name and
colour. Return undefined for a name you don't recognise to fall back entirely.
Identity is declared twice because two layers write it. currentUser is
pdfviewcn's — every pin, reply, and status this component writes carries its
name as the author. annotationAuthor is the annotation plugin's, covering
what the plugin creates on its own, like a rectangle from the shape tool. Feed
both from one object; let them drift and the same person shows up twice.
Editing never re-attributes. /T is who made the annotation, so pdfviewcn hands the
existing author back on every update it writes and moves /M instead — see Who
made it.
annotationAuthor is plugin config, and the plugin has no runtime setter for
it — the name is fixed when plugins is built, and changing that array
rebuilds the engine. Where sign-in resolves after the viewer mounts, build the
registration once you know the name, or accept the configured default on marks
drawn before then.
Examples#
Default#
Arm the comment tool, then click the page: a pin drops and the composer opens on
it. Send it and the comment enters the document — click the pin again to read
the thread, reply, and edit or delete any message. The preview above also mounts
<PdfCommentDraftLayer />, so closing
without sending leaves nothing behind.
"use client";
import { createPluginRegistration } from "@embedpdf/core";With a sidebar index#
The sidebar lists every thread. Clicking a row selects its pin and scrolls to it, where the popover opens for reading and replying — the panel indexes, the card converses.
"use client";
import { createPluginRegistration } from "@embedpdf/core";Commenting on an annotation#
Highlights and shapes get a thread too. Compose <PdfCommentThreadButton />
into the
selection menu and a selected
annotation grows a comment button — showing the message count when a
conversation already exists, and opening the thread in a popover:
selectionMenu={(props) =>
isPdfCommentPin(props.context.annotation.object) ? (
<PdfCommentPopover documentId={documentId} {...props} />
) : (
<PdfAnnotationSelectionMenu documentId={documentId} {...props}>
<PdfCommentThreadButton
documentId={documentId}
annotation={props.context.annotation.object}
/>
</PdfAnnotationSelectionMenu>
)
}The comment becomes a Text annotation linked to the highlight by inReplyToId —
the PDF format's own threading mechanism (ISO 32000-1 §12.5.6.2), where the
parent may be any markup annotation. So it carries an author and a timestamp, it
can be replied to in turn, and it reopens in Acrobat as a reply chain.
Nothing about the highlight changes. It's still "Highlight" in the annotation
sidebar, and the conversation shows up in the comment sidebar like any pin —
labelled on highlight so the thread stays attached to its subject.
pdfviewcn reads the annotation's own contents — it surfaces as the thread's
opening message — and never writes it. Acrobat and Preview both put imported
notes there, and it holds one unattributed, undated string, so authoring into it
means silently destroying somebody else's.
Respecting locked annotations#
The thread reads the document's modify permission through
useDocumentPermissions: when the document forbids
modifying annotations, the reply box and every message's edit/delete menu are
hidden, leaving a read-only thread rather than controls that fail silently.
Accessibility#
Placing a comment moves focus into the composer's field. Sending is an ordinary
button, enabled only when there's text; Enter sends, Shift + Enter breaks
the line, and Escape cancels. Cmd/Ctrl + Enter sends as well, for
fingers trained on GitHub-style composers. While an IME candidate list is open,
Enter picks the candidate instead of sending. Each message's edit/delete lives
behind a labelled overflow menu.
Selecting a pin requires a pointer, since selection happens by clicking the pin on the page — there's no keyboard equivalent for placing or picking annotations.
API Reference#
PdfCommentUserProvider#
| Prop | Type | Default | Description |
|---|---|---|---|
currentUser | PdfCommentUser | { name: "You" } | The person authoring comments here. Their name is written to each pin and reply. |
resolveUser | (name: string) => Partial<PdfCommentUser> | undefined | — | Map a stored author name to a richer identity. Merged over the defaults; omit anything unknown. |
usePdfCommentUser#
Reads identity from the nearest provider, or the defaults when there's none.
Returns { currentUser, resolveUser }, where resolveUser(name) always yields a
complete PdfCommentUser (defaults filled in).
PdfCommentUser#
interface PdfCommentUser {
/** Display name, and the value written to the annotation's `author`. */
name: string;
/** Optional avatar image URL. Falls back to coloured initials. */
avatar?: string;
/** Optional accent colour. Defaults to a deterministic colour from the name. */
color?: string;
}PdfCommentAvatar#
An <Avatar /> for a resolved author — their photo when one is supplied,
otherwise their initials on the resolved colour. Takes user: PdfCommentUser
plus every <Avatar /> prop.
PdfCommentPin#
A <button> carrying data-slot="pdf-comment-pin" and a data-selected
attribute when selected. Accepts every <button> prop (except onSelect, which
is typed for the layer), plus:
| Prop | Type | Default | Description |
|---|---|---|---|
annotation | PdfAnnotationObject | — | From the layer. The comment pin to render; its author resolves the avatar. |
isSelected | boolean | — | From the layer. Draws the accent ring and holds the hover preview shut. |
onSelect | (event: React.MouseEvent) => void | — | From the layer. Selects the annotation; wired to the button's onClick. |
The annotation plugin still owns the pin's position, size, and selection; the marker just fills the pin's box, so it tracks the annotation as the page scrolls and scales with zoom.
The hover preview#
Hovering (or focusing) a pin that already has text grows the marker itself into
a preview — author, relative time, and the body clamped to two lines. There's no
second element and no portal: the avatar and the preview are one box carrying
data-slot="pdf-comment-pin-card", so the two states read as one object
changing size rather than a card appearing beside a dot.
The box is anchored top-0 left-0 and only padding and box-shadow
transition, over 75ms; width and height stay intrinsic, so they snap rather than
reflowing the text on every frame. That top-left corner is squared off
(rounded-tl-none) so the marker reads as a teardrop pointing at it — it's the
annotation rect's origin, the one point the layer keeps nailed to the page as
you zoom, since a comment is a noZoom annotation whose box stays 24px on
screen at every scale.
The preview stays shut while the pin is selected, since the popover is already
showing the whole thread, and on a pin with no text there's nothing to show. It
carries data-slot="pdf-comment-pin-preview", so its width or clamp is
adjustable from the pin:
<PdfCommentPin
annotation={annotation}
isSelected={isSelected}
onSelect={onSelect}
className="**:data-[slot=pdf-comment-pin-preview]:w-56"
/>The card and the halo around it are paper-coloured in both schemes: a pin lies
on the page, and a --popover card would go near-black in dark mode on a page
that stayed white. Both read --pdf-page; see Recolouring the
paper.
Selection#
Selection draws a paper-coloured gap and then an accent band outside it, both
following the teardrop. The accent is var(--pdf-annotation-selection) — the
variable <PdfAnnotationLayer /> draws
the rest of its selection chrome in — so setting it once anywhere above the
viewer recolours the pin along with everything else. It's the only chrome a
selected pin gets; see the selection box above.
Cursors come from two places. At rest the pin shows a pointer, because a click
is all it accepts. Once selected the plugin lays its own drag surface over the
marker and that surface carries cursor: move, so the drag cursor appears
exactly when dragging is possible — the plugin only enables dragging on the
selected annotation.
PdfCommentPopover#
A <div> carrying data-slot="pdf-comment-popover", rendered inside the layer's
positioning wrapper. Accepts the layer's render-prop payload plus documentId,
and every remaining div prop.
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The active document. Pass it from the pageLayers scope. |
selected | boolean | — | From the layer. The card renders null unless the pin is selected. |
context | AnnotationSelectionContext | — | From the layer. The selected annotation and its derived flags. |
menuWrapperProps | MenuWrapperProps | — | From the layer. Positioning + counter-rotation; spread onto the wrapper as-is. |
rect | Rect | — | From the layer. The pin's viewport-scaled bounding box, used to offset the card. |
Returns null when nothing is selected or the annotation plugin isn't
registered. The card is keyed by the selected pin's id, so switching selection
starts a new card rather than handing the previous pin's half-written draft to
the next one.
Comments and replies are written through the annotation scope's
createAnnotation/updateAnnotation; deleting a message calls
deleteAnnotation.
Whether the card shows the composer or the thread is read from the pin's text on every render rather than latched when the card opens. The layer re-renders this card from plugin state on every change, so undoing the first comment puts the card back into the composer instead of leaving a thread wrapped around an empty root.
This card never deletes a pin. With
<PdfCommentDraftLayer /> mounted
pdfviewcn doesn't write empty ones, so a pin with nothing on it was emptied by
an undo or authored somewhere else. The card offers to fill it in rather than
removing it out from under whoever placed it; deleting is the trash button's
job.
PdfCommentThread#
A <div> carrying data-slot="pdf-comment-thread". Accepts every <div> prop,
plus:
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The document the thread reads and writes. |
entry | SidebarAnnotationEntry | — | The root annotation and its replies, from findPdfCommentThread. |
Returns null when the annotation plugin isn't registered. The reply box and
each message's edit/delete menu are hidden when the document forbids modifying
annotations. See Permissions for the full model.
The root renders as a message only when it has something to say — a pin's own
text, or a contents note some other reader left on a shape. A square you drew
is artwork, so the thread opens straight at its replies. Editing and deleting
are offered on a pin's root and on replies; a shape's root isn't a message and
isn't edited from here.
Rendering a thread outside the popover — in a panel of your own, say — means resolving its entry through the shared finder, so your panel, the popover, and the sidebar always agree:
import { useAnnotation } from "@embedpdf/plugin-annotation/react";
function Thread({ documentId, id }: { documentId: string; id: string }) {
const { state } = useAnnotation(documentId);
const entry = findPdfCommentThread(state, id);
if (!entry) return null;
return <PdfCommentThread documentId={documentId} entry={entry} />;
}PdfCommentThreadButton#
The comment affordance for an annotation that isn't a comment. A <Button>
carrying data-slot="pdf-comment-thread-button" that opens the annotation's
thread in a popover. Accepts every <Button /> prop except children, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The document the thread reads and writes. |
annotation | PdfAnnotationObject | — | The annotation being discussed — the thread's root. |
With no conversation yet it's an icon button labelled "Comment on this
annotation"; once there is one it shows the message count. Composed into
<PdfAnnotationSelectionMenu /> —
see Commenting on an annotation.
Returns null when the annotation plugin isn't registered or the annotation
isn't in plugin state yet. Replies are written through the annotation scope's
createAnnotation as Text annotations carrying inReplyToId and
replyType: Reply, positioned on the root's rect.
PdfCommentMessage#
One message: avatar, author, relative time, body, and an optional corner
action (typically the overflow menu). Takes comment: PdfAnnotationObject, an
optional action node, and every <div> prop. The body carries
data-slot="pdf-comment-message-body", so an ancestor can truncate or restyle
it without replacing the message.
PdfCommentActions#
The edit/delete overflow menu. Takes onEdit: () => void and
onDelete: () => void.
PdfCommentComposer#
A growing message field with a send button. Manages its own text and calls
onSubmit with the trimmed value.
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit | (text: string) => void | — | Called with the trimmed text on send. |
onCancel | () => void | — | Called on Escape, when provided. |
value | string | — | Controls the field. Uncontrolled when omitted. |
onValueChange | (text: string) => void | — | Called as the field changes, for the controlled state. |
placeholder | string | "Reply…" | Field placeholder and aria-label. |
submitLabel | string | "Send" | Accessible label for the send button. |
autoFocus | boolean | — | Focus the field on mount. |
Pass value and onValueChange to hoist the text somewhere that outlives the
field. <PdfCommentDraftLayer /> does
exactly that, since an unposted comment has to survive its card closing. A
controlled composer doesn't clear itself on send — whoever owns the value
decides what happens to it.
PdfCommentStatus#
The review status control: a quiet icon button while a thread is untriaged, wearing the verdict once someone has ruled on it. Rendered in the header of both comment cards.
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | — | The document the thread belongs to. |
entry | SidebarAnnotationEntry | — | The thread to read and write status on. |
Everything else forwards to <Button>.
On a read-only document it degrades to <PdfCommentStatusBadge> — a review
still shows where it landed, it just can't be moved.
PdfCommentStatusBadge#
The same status as a read-only chip, for a list row or a header. Takes
state?: PdfAnnotationState and every <span> prop, and renders nothing when
the thread is untriaged so an unreviewed list stays quiet.
Model helpers#
The functions that draw the annotation/comment line. They're plain functions over plugin state, so anything reading annotations can apply the same rule rather than re-deriving it:
| Function | Returns | Description |
|---|---|---|
isPdfCommentPin(annotation) | boolean | Whether an annotation is a comment pin — a Text annotation that isn't itself a reply. |
isPdfCommentThread(entry) | boolean | Whether a sidebar entry is a conversation — has anyone actually spoken here? |
getPdfCommentThreads(state) | SidebarAnnotationEntry[] | Every conversation in the document. What the comment sidebar lists. |
findPdfCommentThread(state, id) | SidebarAnnotationEntry? | The thread anchored to one annotation, spoken in or not — what the composer needs. |
getPdfCommentMessages(entry) | PdfAnnotationObject[] | Everything anyone actually said, in order — status replies filtered out. |
countPdfCommentMessages(entry) | number | Messages in a thread: the root's own note, plus every reply. |
getPdfCommentLead(entry) | PdfAnnotationObject? | The message a thread opens with — the root's note, or the first reply if it has none. |
findPdfCommentStatus(entry, author?) | PdfTextAnnoObject? | The status reply — that author's, or the thread's latest. |
A pin nobody has typed into yet isn't a thread, so an empty one never shows up in the sidebar however it got there.
Presentation helpers#
What <PdfCommentAvatar /> uses to stand in for a person with no picture.
Exported so a row of your own matches:
| Function | Returns | Description |
|---|---|---|
getPdfCommentInitials(name) | string | First letter of the first and last word, or the first two of a single name. |
getPdfCommentUserColor(name) | string | A deterministic accent colour for a name, stable across sessions and machines. |
Timestamps come from formatTimestamp(date) in @/registry/lib/pdf-format —
"just now", "5 minutes ago", "2 days ago". It sits in lib because the
annotation panel dates its rows the same way.
On This Page
AboutAnnotations and commentsReview statusIdentityCompanion componentsInstallationUsageThe selection boxSetting the authorExamplesDefaultWith a sidebar indexCommenting on an annotationRespecting locked annotationsAccessibilityAPI ReferencePdfCommentUserProviderusePdfCommentUserPdfCommentUserPdfCommentAvatarPdfCommentPinThe hover previewSelectionPdfCommentPopoverPdfCommentThreadPdfCommentThreadButtonPdfCommentMessagePdfCommentActionsPdfCommentComposerPdfCommentStatusPdfCommentStatusBadgeModel helpersPresentation helpers