Back to Blog

How to Add PDF Annotations in React — Complete Guide

~10 min read
InkLayer

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 TypeAnnotation NeedsKey Requirement
Document reviewHighlights, comments, threaded repliesCollaborative annotations with user tracking
Legal / ComplianceSignatures, stamps, redactionsVerifiable audit trail, export to standard PDF
Online educationFreehand drawing, sticky notes, underlinesLow latency ink, pressure-sensitive strokes
Medical imagingShapes, arrows, measurementsPixel-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.
  • Geometry types: rect, quad (for text selections), path (for freehand), line (for arrows), poly (for shapes).
  • kind/payload separation: The kind describes what the annotation is (e.g., text-markup), while payload defines which variant (e.g., highlight vs underline). 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

  1. Pointer event on the canvas → hit detection determines which annotation (if any) is underneath
  2. Selection state updates → the annotation is highlighted, its resize handles appear
  3. Drag/Resize → geometry is recalculated in PDF User Space
  4. Render → the Konva layer redraws the annotation at the new coordinates
  5. 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.

VariantAppearanceUse Case
highlightTranslucent colored rectangleMarking important passages
underlineLine below textDrawing attention to specific phrases
strikeoutLine through textMarking 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

KindVariantsUse Case
stampCustom image stamps”Approved”, “Reviewed”, company seals
shaperect, ellipse, cloudDrawing attention to regions
lineArrow, straight linePointing to specific areas

Difficulty to implement from scratch:

Annotation TypeComplexityWhy
Text MarkupMediumText selection in PDF.js is non-trivial
CommentsMediumRequires UI + threading + persistence
InkHardSmooth strokes, pressure handling, eraser
ShapesHardResizing, rotation, hit testing at zoom
StampsEasyRect 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:

  1. Capture text selection coordinates (not trivial — PDF.js text layer API is complex)
  2. Draw colored rectangles on a separate canvas layer
  3. Implement hit detection for selection and deletion
  4. Handle zoom/scroll coordinate transformations
  5. Serialize all annotation data for persistence
  6. 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:

LibraryLanguageAnnotationsStarsMaintenance
InkLayerReact + Vue14 typesActive✅ Active
react-pdf-highlighterReactHighlight + comment3K+⚠️ Minimal
pdfjs-annotation-extensionVanilla JSFull setActive⚠️ 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.

FactorOpen-SourceCommercial
CostFree (MIT)$2,000–$50,000+/year
Setup timeA few hoursSame day
CustomizationFull control over UI and dataLimited by vendor API
HIPAA/SOC2Your responsibilityIncluded
Offline supportBuilt-in (local PDF.js)Depends on vendor
Best forStartups, internal tools, custom UXEnterprise, 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:

PropTypeDescription
urlstring | URLPDF file URL to load
datastring | ArrayBufferDirect 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
enableRangeboolean | "auto"Streaming mode for large PDFs
layoutStyleCSSPropertiesContainer 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:

  1. User selects text → PDF.js provides QuadPoints (sets of 4 corner coordinates)
  2. Core maps quads to a QuadGeometry object
  3. The annotation is created with kind: "text-markup" and variant: "highlight"
  4. Konva layer renders a translucent colored rectangle at those coordinates
  5. onAnnotationAdded fires 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

MetricTargetStrategy
LCP< 2.5sLazy-load PDF after first paint; show skeleton UI
INP< 200msDebounce render; avoid React re-renders on scroll
CLS< 0.1Fixed container dimensions; avoid PDF layout shifts

Choosing the Right PDF Annotation Approach for Your Project

Here’s a decision framework:

  1. Internal tool, prototype, or startup MVP → Open-source library (InkLayer, pdfjs-annotation-extension). You get full control, zero cost, and can ship in hours.

  2. Mid-market SaaS with custom UX requirements → Open-source library with custom toolbar and sidebar. The MIT license lets you modify everything.

  3. Enterprise with compliance needs (HIPAA, SOC2, FedRAMP) → Evaluate commercial SDKs. The compliance paperwork alone often justifies the cost.

  4. 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 subtype Highlight
  • shape + shape: rect → PDF subtype Square
  • ink → PDF subtype Ink

What’s Next?

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.

Ready to build PDF annotation features?

InkLayer provides a complete PDF annotation SDK for React & Vue — 14 annotation types, pixel-perfect rendering, and one-command setup.