0

PDF Annotation Sidebar

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.

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 hidden flag. A hidden annotation stops being drawn and stops being clickable.
  • Lock — toggles the PDF locked flag, 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.

PropTypeDefaultDescription
documentIdstring | nullThe ambient documentWhich document's annotations to list.
headingReact.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:

PropTypeDefaultDescription
objectPdfAnnotationObjectThe annotation to draw.
toolIdstringThe tool whose icon to fall back to when it can't.
iconReact.ComponentTypeDraws 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.