PDF annotations transform static documents into interactive review tools. If you’re building a document collaboration platform, an online education tool, or a contract review system, the ability to highlight text, add comments, draw freehand, and place stamps directly on PDFs is often the core feature your users expect.
In this guide, I’ll walk through the complete architecture of PDF annotation in React — from understanding how PDF.js works under the hood to implementing a fully functional annotation system with toolbar, comment threading, and export capabilities. Every code example is drawn from real-world SDK patterns, not hand-wavy pseudocode.
What Are PDF Annotations and Why Do You Need Them in React?
Annotations are interactive overlays on a PDF page. Unlike simple text overlays, PDF annotations are spatially aware — they know their position on the page, maintain their appearance across zoom levels, and can be serialized, stored, and reloaded.
Real-world use cases
| App Type | Annotation Needs | Key Requirement |
|---|---|---|
| Document review | Highlights, comments, threaded replies | Collaborative annotations with user tracking |
| Legal / Compliance | Signatures, stamps, redactions | Verifiable audit trail, export to standard PDF |
| Online education | Freehand drawing, sticky notes, underlines | Low latency ink, pressure-sensitive strokes |
| Medical imaging | Shapes, arrows, measurements | Pixel-precise rendering at high zoom levels |
What PDF.js gives you out of the box
PDF.js — Mozilla’s JavaScript PDF renderer — supports a handful of native annotation types: FREETEXT, HIGHLIGHT, STAMP, and INK. Here’s what it doesn’t give you:
- No customizable annotation toolbar or UI
- No comment threading or reply system
- No serialization/deserialization for persistence
- No programmatic annotation creation API
- No undo/redo support
- No stamp customization
- No shape annotations (rectangles, ellipses, arrows)
In short: PDF.js renders PDFs. It does not provide an annotation experience. That’s where annotation libraries come in.
The Architecture of a React PDF Annotation System
Before writing code, it’s worth understanding the three-layer model that every annotation system follows:
┌─────────────────────────────────────┐
│ Annotation UI (React components) │ ← Toolbar, sidebar, comment box
├─────────────────────────────────────┤
│ Annotation Data Model (Core) │ ← JSON objects, coordinate system
├─────────────────────────────────────┤
│ PDF Renderer (PDF.js / Konva) │ ← Canvas-based page rendering
└─────────────────────────────────────┘
Layer 1: PDF Renderer
PDF.js renders each page to a <canvas> element. On top of that canvas, a separate drawing layer (typically Konva.js) overlays annotation graphics. This separation is critical: it means annotations can be dragged, resized, and deleted without re-rendering the underlying PDF.
Layer 2: Annotation Data Model
Annotations are stored as JSON objects with a well-defined schema. Here’s a real annotation object from InkLayer’s core data model:
{
"id": "7iTCOVHhhkjSft3sHjGOt",
"kind": "text-markup",
"target": {
"pageIndex": 0,
"geometry": {
"type": "quad",
"quads": [{
"p1": { "x": 233.37, "y": 107.75 },
"p2": { "x": 361.89, "y": 107.75 },
"p3": { "x": 233.37, "y": 155.45 },
"p4": { "x": 361.89, "y": 155.45 }
}]
},
"coordinateSystem": "pdf-user-space"
},
"payload": {
"kind": "text-markup",
"variant": "highlight",
"color": "#b4fa56"
},
"appearance": {
"strokeColor": "#b4fa56",
"fillColor": "rgba(180, 250, 86, 0.3)",
"opacity": 1
}
}
Key concepts:
coordinateSystem: All coordinates are in PDF User Space — the same coordinate system PDF.js uses internally. This means annotations stay perfectly aligned at any zoom level.Geometrytypes:rect,quad(for text selections),path(for freehand),line(for arrows),poly(for shapes).kind/payloadseparation: Thekinddescribes what the annotation is (e.g.,text-markup), whilepayloaddefines which variant (e.g.,highlightvsunderline). This makes serialization clean and extensible.
Layer 3: Annotation UI
The React layer provides the toolbar, sidebar, comment thread components, and event handling. It communicates with the data model through a core API:
User clicks "Highlight" tool → core activates highlight mode
User selects text on canvas → core captures text quads
Core creates annotation object → annotation appears on canvas
Core fires onAnnotationAdded callback → React updates sidebar
Event Flow: Click to Render
- Pointer event on the canvas → hit detection determines which annotation (if any) is underneath
- Selection state updates → the annotation is highlighted, its resize handles appear
- Drag/Resize → geometry is recalculated in PDF User Space
- Render → the Konva layer redraws the annotation at the new coordinates
- Save → all annotations are serialized to JSON for persistence
Types of PDF Annotations You Can Add
Text Markup (TextMarkup)
The most common annotation type. Works by capturing text selection coordinates (Quads) and rendering colored overlays on top.
| Variant | Appearance | Use Case |
|---|---|---|
highlight | Translucent colored rectangle | Marking important passages |
underline | Line below text | Drawing attention to specific phrases |
strikeout | Line through text | Marking content for deletion |
Freehand Drawing (Ink)
Freeform strokes captured as a series of points. Supports variable stroke width and color — essential for signatures and hand-drawn notes.
Comments & Notes
Click-to-place text annotation with a popup note. Supports threaded replies, making it ideal for collaborative review workflows.
Stamps & Shapes
| Kind | Variants | Use Case |
|---|---|---|
stamp | Custom image stamps | ”Approved”, “Reviewed”, company seals |
shape | rect, ellipse, cloud | Drawing attention to regions |
line | Arrow, straight line | Pointing to specific areas |
Difficulty to implement from scratch:
| Annotation Type | Complexity | Why |
|---|---|---|
| Text Markup | Medium | Text selection in PDF.js is non-trivial |
| Comments | Medium | Requires UI + threading + persistence |
| Ink | Hard | Smooth strokes, pressure handling, eraser |
| Shapes | Hard | Resizing, rotation, hit testing at zoom |
| Stamps | Easy | Rect overlay with image |
See the full API reference for all 14 annotation types with code examples.
Approaches to Adding PDF Annotations in React
There are three approaches. Choose based on your timeline and requirements.
Approach 1: Build from Scratch with Raw PDF.js
// Minimum viable highlight with raw PDF.js
import * as pdfjsLib from 'pdfjs-dist'
async function renderPdf(canvasRef, url) {
const pdf = await pdfjsLib.getDocument(url).promise
const page = await pdf.getPage(1)
const viewport = page.getViewport({ scale: 1.5 })
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ canvasContext: ctx, viewport }).promise
}
This gives you a rendered page. To add a highlight, you’d need to:
- Capture text selection coordinates (not trivial — PDF.js text layer API is complex)
- Draw colored rectangles on a separate canvas layer
- Implement hit detection for selection and deletion
- Handle zoom/scroll coordinate transformations
- Serialize all annotation data for persistence
- Implement undo/redo
Reality check: This route typically takes 4-8 weeks for a minimally usable annotation system. You’ll spend 80% of that time on edge cases: multi-page text selection, right-to-left text, coordinate drift at high zoom, and browser compatibility.
Approach 2: Use an Open-Source Annotation Library
Open-source libraries built on PDF.js provide pre-built annotation UIs and data models. Key options:
| Library | Language | Annotations | Stars | Maintenance |
|---|---|---|---|---|
| InkLayer | React + Vue | 14 types | Active | ✅ Active |
| react-pdf-highlighter | React | Highlight + comment | 3K+ | ⚠️ Minimal |
| pdfjs-annotation-extension | Vanilla JS | Full set | Active | ⚠️ Minimal |
Trade-offs:
- ✅ Free (MIT), fully customizable, no vendor lock-in
- ⚠️ You own deployment and maintenance
- ⚠️ Community support varies
Approach 3: Use a Commercial SDK
Commercial options like Nutrient (PSPDFKit), Foxit PDF SDK, and Apryse provide fully polished annotation experiences with dedicated support.
| Factor | Open-Source | Commercial |
|---|---|---|
| Cost | Free (MIT) | $2,000–$50,000+/year |
| Setup time | A few hours | Same day |
| Customization | Full control over UI and data | Limited by vendor API |
| HIPAA/SOC2 | Your responsibility | Included |
| Offline support | Built-in (local PDF.js) | Depends on vendor |
| Best for | Startups, internal tools, custom UX | Enterprise, regulated industries |
Step-by-Step Tutorial: Add PDF Annotations to Your React App
Now for the practical part. We’ll use InkLayer as the annotation library — it’s PDF.js-native, MIT-licensed, and covers all 14 annotation types. The patterns apply to any PDF.js-based library.
5.1 Project Setup
Create a new Vite + React project:
npm create vite@latest pdf-annotator-demo -- --template react-ts
cd pdf-annotator-demo
npm install inklayer-react
Your package.json dependencies will look like:
{
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"inklayer-react": "^0.1.0"
}
}
Import the styles globally in src/main.tsx:
import 'inklayer-react/style'
5.2 Render a PDF with a Built-in Viewer
The PdfAnnotator component wraps a fully functional PDF viewer with page navigation, zoom controls, and search built in:
import { PdfAnnotator } from 'inklayer-react'
import 'inklayer-react/style'
function App() {
return (
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: 'Alice' }}
locale="en-US"
/>
)
}
export default App
That’s it. You get a full PDF viewer with annotation capability enabled by default. The user prop identifies who’s making annotations — essential for collaborative scenarios.
Props you’ll commonly use:
| Prop | Type | Description |
|---|---|---|
url | string | URL | PDF file URL to load |
data | string | ArrayBuffer | Direct PDF binary data (bypasses URL fetch) |
user | { id, name } | Current user identity |
appearance | "auto" | "dark" | "light" | Theme mode |
theme | "violet" | "blue" | ... | Accent color (25+ options) |
locale | "en-US" | "zh-CN" | Interface language |
enableRange | boolean | "auto" | Streaming mode for large PDFs |
layoutStyle | CSSProperties | Container dimensions |
5.3 Enable the Annotation Toolbar
The annotator comes with a toolbar built in, but you can configure which tools are available and add custom actions:
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: 'Alice' }}
locale="en-US"
defaultShowAnnotationsSidebar={false}
actions={(props) => (
<>
<button onClick={() => props.save()}>
💾 Save
</button>
<button onClick={() => console.log(props.getAnnotations())}>
📦 Get Annotations
</button>
<button onClick={() => props.exportToPdf('Export PDF')}>
📄 Export PDF
</button>
<button onClick={() => props.exportToExcel('Export Excel')}>
📊 Export Excel
</button>
</>
)}
/>
The actions render prop gives you access to the core API methods: save(), getAnnotations(), exportToPdf(), and exportToExcel().
5.4 Add Highlights and Text Markup
Highlights work through text selection — the user selects text in the PDF, and the library captures the quad coordinates and renders the highlight overlay.
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: 'Alice' }}
locale="en-US"
onAnnotationAdded={(annotation) => {
console.log('Added:', annotation.id, annotation.kind)
// annotation.kind → "text-markup"
// annotation.payload.variant → "highlight"
}}
onAnnotationSelected={(annotation, isClick) => {
console.log('Selected:', annotation?.id, 'via click:', isClick)
}}
/>
How it works under the hood:
- User selects text → PDF.js provides
QuadPoints(sets of 4 corner coordinates) - Core maps quads to a
QuadGeometryobject - The annotation is created with
kind: "text-markup"andvariant: "highlight" - Konva layer renders a translucent colored rectangle at those coordinates
onAnnotationAddedfires with the full annotation object
5.5 Add Comments and Sticky Notes
Comments are text annotations placed at a specific location on the page. They support threaded replies:
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: 'Alice' }}
locale="en-US"
onAnnotationUpdated={(annotation) => {
// Called when a comment is added, edited, or replied to
console.log('Updated:', annotation.id)
}}
/>
The comment data structure includes threaded replies:
{
"kind": "stamp",
"meta": {
"authorId": { "id": "9527", "name": "InkLayer" },
"createdAt": "D:20260629000818+08'00'"
},
"extensions": {
"legacy": {
"comments": [
{
"id": "NYGCSZtaKLS",
"title": "InkLayer",
"date": "D:20260629000922+08'00'",
"content": "Yes, looks good to me!"
}
]
}
}
}
5.6 Freehand Drawing and Signatures
Ink annotations allow freeform drawing with configurable stroke width and color:
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: 'Alice' }}
locale="en-US"
defaultOptions={{
signature: {
defaultSignature: [
'data:image/png;base64,...'
],
defaultFont: [
{ label: 'Kaiti', value: 'STKaiti', external: false },
{ label: 'Handwriting', value: 'customFont', external: true, url: '/fonts/handwriting.ttf' },
]
}
}}
/>
The ink annotation stores stroke paths as a series of points:
{
"kind": "ink",
"target": {
"geometry": {
"type": "path",
"points": [
{ "x": 405.34, "y": 310.56 },
{ "x": 410.94, "y": 315.56 },
{ "x": 418.14, "y": 315.56 }
],
"closed": false
}
},
"payload": {
"kind": "ink",
"color": "#ffe066",
"width": 10
}
}
5.7 Save, Load, and Export Annotations
The onSave callback provides all annotations in a single JSON array — store it in your backend or localStorage:
import { useCallback } from 'react'
import type { Annotation } from 'inklayer-react'
function App() {
const handleSave = useCallback((annotations: Annotation[]) => {
// Send to backend
fetch('/api/annotations', {
method: 'POST',
body: JSON.stringify({ annotations }),
headers: { 'Content-Type': 'application/json' }
})
}, [])
return (
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: 'Alice' }}
onSave={handleSave}
/>
)
}
Loading annotations on page mount:
function App() {
const [initialAnnotations, setInitialAnnotations] = useState<Annotation[]>([])
useEffect(() => {
fetch('/api/annotations?docId=123')
.then(r => r.json())
.then(data => setInitialAnnotations(data.annotations))
}, [])
return (
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: 'Alice' }}
initialAnnotations={initialAnnotations}
onSave={handleSave}
/>
)
}
Exporting: The library supports two export formats:
- PDF export: Burns annotations into the PDF, producing a standard PDF anyone can open
- Excel export: Exports annotations as a structured spreadsheet (useful for review logs)
5.8 Bonus: Programmatic Annotations
You can create annotations from code without user interaction — useful for auto-highlighting search terms or pre-filling review data:
import { useRef } from 'react'
import { PdfAnnotator, type AnnotationCore } from 'inklayer-react'
function App() {
const coreRef = useRef<AnnotationCore>(null)
const autoHighlight = () => {
coreRef.current?.addAnnotation({
type: 'highlight',
pageIndex: 0,
rects: [{ x: 100, y: 200, width: 300, height: 20 }],
color: '#FFEB3B',
opacity: 0.4,
})
}
return (
<div>
<button onClick={autoHighlight}>Auto-Highlight Section</button>
<PdfAnnotator
url="https://inklayer.dev/inklayer-demo.pdf"
user={{ id: 'user-1', name: 'Alice' }}
annotationCoreRef={coreRef}
/>
</div>
)
}
Performance Considerations for Production
Virtualized Rendering
With 100+ annotations, rendering every single one on every frame will tank performance. The solution is viewport culling — only render annotations visible in the current viewport.
InkLayer’s Konva adapter handles this automatically: only annotations whose bounding rectangles intersect the visible viewport are rendered. At 300% zoom, you might see only 5-10 annotations even if the document has hundreds. For implementation specifics, see the React SDK performance docs.
Debouncing Zoom and Scroll
Redrawing on every scroll event causes stuttering. Debounce redraws by 50-100ms:
// Internal pattern used by the Konva adapter
const debouncedRedraw = debounce(() => {
layer.batchDraw()
}, 50)
Core Web Vitals Targets
| Metric | Target | Strategy |
|---|---|---|
| LCP | < 2.5s | Lazy-load PDF after first paint; show skeleton UI |
| INP | < 200ms | Debounce render; avoid React re-renders on scroll |
| CLS | < 0.1 | Fixed container dimensions; avoid PDF layout shifts |
Choosing the Right PDF Annotation Approach for Your Project
Here’s a decision framework:
-
Internal tool, prototype, or startup MVP → Open-source library (InkLayer, pdfjs-annotation-extension). You get full control, zero cost, and can ship in hours.
-
Mid-market SaaS with custom UX requirements → Open-source library with custom toolbar and sidebar. The MIT license lets you modify everything.
-
Enterprise with compliance needs (HIPAA, SOC2, FedRAMP) → Evaluate commercial SDKs. The compliance paperwork alone often justifies the cost.
-
Regulated industry with offline requirements → PDF.js-based open-source solutions have a key advantage: they run entirely in the browser with no server dependency.
Frequently Asked Questions
Does PDF.js support annotations out of the box?
Yes, but only 4 basic types (FREETEXT, HIGHLIGHT, STAMP, INK). There’s no annotation toolbar, no comment threading, no undo/redo, and no serialization API. You need an annotation library for a usable annotation experience.
How do annotations persist across page reloads?
Annotations are JSON objects. Save them to your backend (or localStorage for prototypes) via the onSave callback, and load them back via the initialAnnotations prop. See the React SDK save & load guide for a complete persistence workflow. The coordinate system is PDF User Space, so annotations remain perfectly aligned regardless of viewport size.
Can I export annotated PDFs for non-technical users?
Yes. Call exportToPdf() to burn all annotations into a new PDF file. The output is a standard PDF that anyone can open in any PDF reader — annotations appear as native PDF annotation objects, not as separate data.
What’s the difference between annotation “kinds” and PDF subtypes?
Annotation kinds are semantic categories (e.g., text-markup, shape, ink) — they describe what the user intended. PDF subtypes (e.g., Highlight, Square, Ink) are the raw PDF specification types. The annotation library maps between them:
text-markup+variant: highlight→ PDF subtypeHighlightshape+shape: rect→ PDF subtypeSquareink→ PDF subtypeInk
What’s Next?
- Full API Reference — all 14 annotation types with code examples
- React SDK Documentation — component props, hooks, and event handlers
- Annotation Data Model — deep dive into the core data schema
- Vue SDK Documentation — same API, Vue 3 composition API style
PDF annotation is a deep topic, but the right library abstracts the hard parts — coordinate mapping, hit detection, and serialization — so you can focus on building your application’s unique annotation experience.