- PDF Viewer
- PDF Toolbar
- PDF More Actions Menu
- PDF Floating Toolbar
- PDF Document Tabs
- PDF View
- PDF Document Grid
- PDF Document Info
- PDF Page Navigation
- PDF Zoom Controls
- PDF Undo/Redo Buttons
- PDF Keyboard Shortcuts
- PDF Bookmark Sidebar
- PDF Thumbnail Sidebar
- PDF Search Panel
- PDF Attachment List
- PDF Annotation Layer
- PDF Annotation Toolbar
- PDF Annotation Inspector
- PDF Annotation Selection Menu
- PDF Annotation Sidebar
- PDF Comment
- PDF Comment Draft
- PDF Comment Button
- PDF Comment Sidebar
- PDF Redaction Toolbar
- PDF Capture Button
- PDF Form Fill Toggle
- PDF Signature Button
- PDF Stamp Button
An index of every mark on the document, grouped by page — each row previews the annotation, credits its author, and carries show, lock, and delete.
"use client";
import { createPluginRegistration } from "@embedpdf/core";About#
<PdfAnnotationSidebar /> answers the question a marked-up page can't: what is actually on this document, and which one am I editing? Everything drawn shows up as a row under its page — a miniature of the annotation itself, a name, and the three controls a layers list is expected to have.
The miniature is drawn from the annotation's own geometry rather than looked up in a table of icons, so a wide highlight reads as a bar, a tall box as a tall box, and a scribble as that scribble. It's what keeps a page of six rectangles scannable: they differ on the page, so they differ in the list.
It's the left half of the design-tool split. The sidebar tells you what exists and lets you reach it; <PdfAnnotationInspector /> sits on the other side and styles whatever the sidebar put into selection.
Rows come from the annotation plugin's own page-grouping selector, so what counts as an annotation is the plugin's definition rather than pdfcn's: links, form widgets, and comment replies stay out, and grouped annotations collapse under their leader.
This is not a layers panel, and rows have no drag handles. embedpdf exposes no reorder or z-order API, so there'd be nowhere to write a new order to — annotations draw in document order, which is the order listed here. If that changes upstream, this is the component that grows handles.
Annotation is a heavy-tier plugin, which means <PdfViewer /> does not register it for you. The sidebar renders nothing at all until AnnotationPluginPackage is on the viewer.
Installation#
pnpm dlx shadcn@latest add pdfcn/annotation-sidebar
The sidebar imports TOOL_ICONS from the toolbar so an annotation reads the same in both places, which is why both files land together.
Usage#
Register the annotation plugin.
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.
const plugins = [createPluginRegistration(AnnotationPluginPackage)];Put the sidebar beside the page canvas.
<PdfViewer documents={[{ url: "/sample.pdf" }]} plugins={plugins}>
<PdfToolbar>
<PdfAnnotationToolbar />
</PdfToolbar>
<div className="flex min-h-0 flex-1">
<PdfAnnotationSidebar className="w-64 shrink-0 border-r" />
<PdfViewerContent
className="flex-1"
pageLayers={({ documentId, pageIndex }) => (
<PdfAnnotationLayer documentId={documentId} pageIndex={pageIndex} />
)}
/>
</div>
</PdfViewer>The sidebar reads documentId from <PdfViewer> context, so it can sit anywhere inside the viewer — a resizable panel, a Sheet, a collapsible rail — with no prop drilling.
Structure#
A row per annotation, under a sticky header per page. Pages with nothing on them aren't listed.
- Preview — a 20px miniature of the annotation's own geometry, in its own colours. Anything with no vector geometry to draw falls back to its tool's icon on a neutral tile, which is what keeps a pale tint legible at that size.
- Name — its tool's name, except where the annotation's text is the annotation. See How rows are named.
- Author — who made the mark, at the end of the row. See Who made it.
- Show — toggles the PDF
hiddenflag. A hidden annotation stops being drawn and stops being clickable. - Lock — toggles the PDF
lockedflag, which forbids moving, resizing, and rotating while still allowing a restyle. - Delete — removes the annotation and its replies.
Show and lock stay visible once they're on, so a hidden or locked annotation is scannable down the list; delete only appears on hover or keyboard focus.
Both flags are real PDF annotation flags rather than client-side state, so they're written into the file and round-trip through other readers.
Examples#
Default#
Draw a few shapes and watch them appear under their page. Click a row to select the annotation and scroll to it.
"use client";
import { createPluginRegistration } from "@embedpdf/core";How rows are named#
Most rows are named after the tool that drew them — "Rectangle", "Highlight", "Ink". Two subtypes are named after their own text instead, because for them the text is the annotation: free text, which is words drawn on the page, and a comment pin, which is words with a marker attached. That's how a design tool names a text layer versus a shape layer, and it's the only label that tells two notes on the same page apart.
Everything else is artwork with a note stapled to it. A note on a square is the
PDF's /Popup text (ISO 32000-1 §12.5.6.2) — a remark about the square, not
its name — so renaming the row to it would be like Figma renaming a rectangle to
the comment somebody left on it. Two people commenting on two shapes would
rename both, and the list would stop describing the drawing entirely.
Comments live in the comment sidebar; this panel stays a list of what's drawn. A shape with a conversation on it shows its message count in the selection menu when you select it, not here.
Who made it#
Each row ends with the annotation's author — /T, the entry the PDF spec uses to
identify who made a markup annotation. On a shared document that's the difference
between six identical rectangles and six people's rectangles, and nobody has to
type it: the plugin stamps it on every mark it creates. A row with no /T simply
ends after its name.
Editing a mark doesn't change who made it. /T is the author, not the last
person to touch it, so every edit pdfcn writes — here, in the
inspector, on a thread — hands the
annotation's own author back and moves /M instead. That matters most on a
document you didn't start: hiding or locking a colleague's rectangle would
otherwise re-sign it to you, in this list and in the file. The one mark it can't
protect is one that arrived with no /T at all — there's nothing to hand back,
and it picks up the configured author the first time anyone edits it.
The row is one line, so it carries /T and not /M. What a list of marks is
scanned for is whose, not when — a modified date is a property of one
annotation, and nobody reads a column of them. /M still shows in the
comment sidebar and on the thread itself,
where a timestamp is part of the conversation rather than a subtitle on a
rectangle.
A group's header credits the group: §12.5.6.2 makes the primary annotation's
/T the group's own, and a subordinate's is to be disregarded in favour of it.
Selecting from the list#
A click selects the annotation and scrolls the page to it. Hold ⌘ (or Ctrl) and click to add rows to the selection instead — the same gesture as on the page, and the only way to build a multi-selection, since there's no marquee upstream.
Selection is shared state, not the sidebar's own: clicking a shape on the page highlights its row, and the list scrolls that row into view.
Groups#
An annotation that leads a group gets a chevron and a count, with its members nested one level beneath it. Groups don't nest upstream, so the tree is never deeper than that.
Selecting any member selects the whole group — that's the plugin's behaviour, and it's what makes a group a group. The per-row show, lock, and delete controls still act on the single annotation they sit on, which is the part that's genuinely useful at member level.
Deleting a group leader deletes the whole group, since members are stored as replies to it.
Hidden annotations#
Hiding takes an annotation out of reach of the page entirely: the layer stops drawing it and the plugin stops hit-testing it, so it can't be selected, styled, or dragged while hidden. That makes this panel the only surface that can bring it back, which is why the row dims rather than disappearing and why its show toggle is never disabled by the annotation's own state.
Clicking a hidden row still scrolls to its page — the only part of the click that can do anything until it's shown again.
When annotating is disabled#
The sidebar reads the active document's modify-annotations permission and disables show, lock, and delete when the flag is denied. The list itself stays readable and selectable, so a read-only document can still be navigated by annotation. See Permissions for the full model.
Delete is additionally disabled for an annotation carrying the PDF locked flag. Hiding something shouldn't also trap it in the document, but locking it should — so the delete gate follows the lock rather than the eye.
Accessibility#
Each page is a <section> labelled with its page number, and rows are list items so the count is announced.
The row itself is a button carrying aria-current when selected. Show, lock, and delete are icon-only buttons whose aria-label names the action they'll perform — a hidden row's toggle reads "Show", a visible one's reads "Hide" — so the state is exposed through the accessible name rather than through the icon alone.
Controls that fade in on hover are pointer-events-none while faded, so an invisible button is never a live click target, and they reveal on keyboard focus as well as on hover.
API Reference#
PdfAnnotationSidebar#
A <div> carrying data-slot="pdf-annotation-sidebar". Accepts every <div> prop.
| Prop | Type | Default | Description |
|---|---|---|---|
documentId | string | null | The ambient document | Which document's annotations to list. |
heading | React.ReactNode | "Annotations" | The panel's title, shown beside a count. null drops the header entirely. |
Returns null when the document isn't loaded or the annotation plugin isn't registered.
Pass heading={null} when the surrounding chrome already names the panel — a tab strip, say — rather than stacking a second title under the first. The annotator app does this for both of its panels.
The header carries data-slot="pdf-annotation-sidebar-header", each page section data-slot="pdf-annotation-sidebar-page", and each row data-slot="pdf-annotation-sidebar-row" plus data-annotation-id and data-selected.
PdfAnnotationPreview#
A <span> carrying data-slot="pdf-annotation-preview", holding the row's miniature. Accepts every <span> prop, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
object | PdfAnnotationObject | — | The annotation to draw. |
toolId | string | — | The tool whose icon to fall back to when it can't. |
icon | React.ComponentType | — | Draws this icon instead of the annotation's geometry. |
Exported so a custom row can use the same miniature the built-in one does. It's aria-hidden — the row's name is the label, and a shape has nothing to read out.
icon is for rows that stand for more than one mark — a group header, where a single member's outline would misrepresent what the row covers. It replaces the drawing outright, colour included, since the tile has stopped standing in for one annotation.
The box sizes itself from className — the sidebar renders it at 20px — and the geometry fits whatever it's given.
Squares, circles, ink, polygons, polylines, lines, and the four text markups draw as real geometry, fitted to the box with their aspect ratio preserved — which is why a highlight across a line stays a bar. Rotation is honoured: a square's unrotatedRect is what was actually drawn, so projecting that and turning it back matches the page rather than the upright box around it.
Stamps, comment pins, free text, and any subtype the engine doesn't model fall back to the tool icon. Stroke width is fixed rather than scaled — a 1pt and a 12pt line both come out unreadable at row size, and what a row has to answer is which shape, not how heavy the pen was. Opacity is honoured with a floor, so a near-transparent highlight doesn't render as an empty square.
Subscription#
Unlike the inspector, this component subscribes to the whole annotation document state through embedpdf's useAnnotation(documentId) rather than through usePdfAnnotationSelector. A panel listing everything has to re-render on every add, delete, restyle, and retitle anyway, so a narrow slice would only buy a deep comparison.