PDF Comment

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.

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. getPdfCommentMessages filters 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? — currentUser is the person authoring comments and replies from this viewer. Their name is written to each new pin and reply.
  • How should an author look? — resolveUser turns 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-in textComment tool 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.

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

PropTypeDefaultDescription
currentUserPdfCommentUser{ 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:

PropTypeDefaultDescription
annotationPdfAnnotationObject—From the layer. The comment pin to render; its author resolves the avatar.
isSelectedboolean—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.

PropTypeDefaultDescription
documentIdstring—The active document. Pass it from the pageLayers scope.
selectedboolean—From the layer. The card renders null unless the pin is selected.
contextAnnotationSelectionContext—From the layer. The selected annotation and its derived flags.
menuWrapperPropsMenuWrapperProps—From the layer. Positioning + counter-rotation; spread onto the wrapper as-is.
rectRect—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:

PropTypeDefaultDescription
documentIdstring—The document the thread reads and writes.
entrySidebarAnnotationEntry—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:

PropTypeDefaultDescription
documentIdstring—The document the thread reads and writes.
annotationPdfAnnotationObject—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.

PropTypeDefaultDescription
onSubmit(text: string) => void—Called with the trimmed text on send.
onCancel() => void—Called on Escape, when provided.
valuestring—Controls the field. Uncontrolled when omitted.
onValueChange(text: string) => void—Called as the field changes, for the controlled state.
placeholderstring"Reply…"Field placeholder and aria-label.
submitLabelstring"Send"Accessible label for the send button.
autoFocusboolean—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.

PropTypeDefaultDescription
documentIdstring—The document the thread belongs to.
entrySidebarAnnotationEntry—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:

FunctionReturnsDescription
isPdfCommentPin(annotation)booleanWhether an annotation is a comment pin — a Text annotation that isn't itself a reply.
isPdfCommentThread(entry)booleanWhether 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)numberMessages 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:

FunctionReturnsDescription
getPdfCommentInitials(name)stringFirst letter of the first and last word, or the first two of a single name.
getPdfCommentUserColor(name)stringA 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.