0

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.

Together they give the flow PDFSlick and Figma have: arm the tool, click the page, and a composer opens right where you dropped the pin. Type the comment, send it, and the card becomes the thread — read, reply, edit, delete, all 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 here overrides what the annotation plugin does — if you want a click to compose a comment instead of writing an empty pin the moment it lands, that's <PdfCommentDraftLayer />, which ships separately because it changes plugin behaviour rather than dressing it.

Annotation, comment

A comment is a kind of annotation, so it's easy to treat every annotation as a comment. pdfcn doesn't. 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 pdfcn draws, and it's the one that makes "no text" rows impossible by construction.

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 pdfcn invented, so a status set here is the one Acrobat shows under Set Status.

It's stored the way the format asks for, which is stranger than it looks — 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.

Installation

pnpm dlx shadcn@latest add pdfcn/comment

Usage

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.

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 annotation normally gets a rectangular outline around its bounding box. A comment's box is 24px square and the pin fills it, so that outline lands as four corners poking out from behind a round marker — it reads as a rendering bug rather than as chrome, and the pin already draws its own accent ring.

<PdfAnnotationLayer> drops it for you: when the only selected annotation is a comment, it sets the outline width to 0, and every other type keeps its box. Nothing to configure.

Under a bare <AnnotationLayer> from the plugin, ask for it yourself — but note that selectionOutline is layer-wide, so this also drops the box for any other annotation type on the same layer:

<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 in two places because two layers write it. currentUser is pdfcn's: every pin, reply, and status this component writes carries currentUser.name as its author, so commenting is attributed correctly on its own. annotationAuthor is the annotation plugin's, and it covers what the plugin creates without going through pdfcn — a rectangle drawn with the shape tool. Feed both from one object and a person's rectangle and their reply read as the same author; let them drift and the same person shows up twice.

Editing never re-attributes. /T is who made the annotation, so pdfcn 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.

What pdfcn deliberately doesn't do is write into the annotation's own contents. That field holds one unattributed, undated string, and Acrobat and Preview both already use it for imported notes — so authoring into it means silently destroying somebody else's. pdfcn reads contents (it surfaces as a thread's opening message) and never writes it.

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> | undefinedMap 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
annotationPdfAnnotationObjectFrom the layer. The comment pin to render; its author resolves the avatar.
isSelectedbooleanFrom the layer. Draws the accent ring and holds the hover preview shut.
onSelect(event: React.MouseEvent) => voidFrom 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.

That identity is what makes it feel solid, and it's why there's no animation library here. 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 — at that duration nobody reads the snap as a jump, and an animated width would reflow the text on every frame instead.

The top-left corner is the one point that never moves, and it's squared off (rounded-tl-none) so the marker reads as a teardrop pointing at it. That corner is the annotation rect's origin — the only 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 while every other corner drifts.

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 is paper-coloured in both light and dark mode, and so is the halo around it. A pin lies on the page, and paper doesn't invert with the colour scheme — a --popover card would go near-black in dark mode on a page that stayed white, and the halo's job is to hold the marker off whatever ink it landed on, which means being the paper. 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
documentIdstringThe active document. Pass it from the pageLayers scope.
selectedbooleanFrom the layer. The card renders null unless the pin is selected.
contextAnnotationSelectionContextFrom the layer. The selected annotation and its derived flags.
menuWrapperPropsMenuWrapperPropsFrom the layer. Positioning + counter-rotation; spread onto the wrapper as-is.
rectRectFrom 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 an empty one isn't something pdfcn writes at all, so a pin with nothing on it was either emptied by an undo or authored somewhere else. Either way it's the document's, not a leftover of ours, and 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
documentIdstringThe document the thread reads and writes.
entrySidebarAnnotationEntryThe 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
documentIdstringThe document the thread reads and writes.
annotationPdfAnnotationObjectThe 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) => voidCalled with the trimmed text on send.
onCancel() => voidCalled on Escape, when provided.
valuestringControls the field. Uncontrolled when omitted.
onValueChange(text: string) => voidCalled as the field changes, for the controlled state.
placeholderstring"Reply…"Field placeholder and aria-label.
submitLabelstring"Send"Accessible label for the send button.
autoFocusbooleanFocus the field on mount.

Pass value and onValueChange to hoist the text somewhere that outlives the field. <PdfCommentDraftLayer /> does exactly that, because an unposted comment has to survive its card being closed. 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
documentIdstringThe document the thread belongs to.
entrySidebarAnnotationEntryThe 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 doesn't show up in the sidebar however it got there. <PdfCommentDraftLayer /> keeps pdfcn from making those; this keeps them from being listed as conversations when they arrive from elsewhere.

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 and shouldn't have to pull in the whole comment module to do it.