How to build text highlight annotations with PDF.js and React
Table of contents
Text highlighting on PDF.js comes down to five steps:
- Track selection — Listen for
pointerupon the viewer container and readwindow.getSelection(). - Identify pages — Walk
range.startContainerandrange.endContainerto find which.pagediv elements the selection spans. - Extract rectangles — Call
range.getClientRects()and subtractpageView.div.getBoundingClientRect()to make them page-relative. - Convert to PDF coordinates — Use
pageView.viewport.convertToPdfPoint(x, y)for each rectangle corner. - Render overlays — Render colored div elements at the stored locations via React portals (see rendering overlays with React portals).
This tutorial references several application-specific helpers: screenToPdf, nodeLocationToScaled, pdfToScreen (coordinate math — see PDF.js coordinate systems), and onCreateAnnotation/setPopoverAnchor/setPendingLocations/setPendingContent (your own state setters). Treat them as integration points.
If you’d rather not build this, Nutrient Web SDK ships NutrientViewer.Annotations.HighlightAnnotation with selection, persistence, and XML Forms Data Format (XFDF) export built in.
Text highlighting is the most common PDF annotation. Users select text, and a colored overlay appears on the selected region. This guide covers how to track text selection on PDF.js pages and convert it into stored annotation data.
Architecture overview
User selects text -> Track selection via `pointerup` event -> Get selected pages from DOM -> Get client rectangles from `Range` -> Convert each rectangle to PDF coordinates -> Save annotation (array of locations) -> Render colored div elements via React portalsStep 1: DOM helpers for PDF.js pages
PDF.js renders each page as a .page element with a data-page-number attribute. You need helpers to navigate this structure:
function getPageFromElement(target) { const node = target.closest(".page"); if (!node) return null;
const canvasWrapper = node.getElementsByClassName("canvasWrapper")[0]; if (!node || !canvasWrapper) return null;
return { node, canvasWrapper, number: Number(node.dataset.pageNumber), };}
function getPagesFromRange(range) { const startParent = range.startContainer.parentElement; const endParent = range.endContainer.parentElement; if (!startParent || !endParent) return [];
const startPage = getPageFromElement(startParent); const endPage = getPageFromElement(endParent); if (!startPage?.number || !endPage?.number) return [];
if (startPage.number === endPage.number) return [startPage];
// Multipage selection. const pages = []; for (let num = startPage.number; num <= endPage.number; num++) { const el = document.querySelector(`[data-page-number='${num}']`); const page = getPageFromElement(el); if (page) pages.push(page); } return pages;}Step 2: Track text selection
Listen for pointerup on the PDF container to capture completed text selections:
import React from "react";
function useTrackSelection(viewer, eventBus, onCreateAnnotation, color, tool) { React.useEffect(() => { const container = viewer?.container; if (!container) return;
const onPointerUp = () => { const selection = window.getSelection(); if (!selection || selection.isCollapsed) return;
const range = selection.getRangeAt(0); if (!range || !container.contains(range.commonAncestorContainer)) return;
const pages = getPagesFromRange(range); if (!pages.length) return;
const rects = getClientRects(range, pages); if (!rects.length) return;
// Convert each rectangle to PDF coordinates. const locations = rects.map((rect) => { const viewport = viewer.getPageView(rect.pageNumber - 1).viewport; const pdfCoords = screenToPdf(rect, viewport); return { page: pdfCoords.pageNumber, rect: [pdfCoords.x1, pdfCoords.y1, pdfCoords.x2, pdfCoords.y2], }; });
const content = range.toString(); if (!content.trim()) return;
// Clear selection. selection.empty(); selection.removeAllRanges();
// Create annotation. onCreateAnnotation(color, locations, content); };
container.addEventListener("pointerup", onPointerUp); return () => container.removeEventListener("pointerup", onPointerUp); }, [viewer, color, tool, onCreateAnnotation]);}Step 3: Get client rectangles
The browser’s Range.getClientRects() returns the bounding boxes of selected text, but they’re in viewport coordinates. You need to adjust them relative to the page canvas:
function getClientRects(range, pages) { const clientRects = Array.from(range.getClientRects()); const rects = [];
for (const clientRect of clientRects) { // Skip tiny rectangles (whitespace/newlines). if (clientRect.width < 1 || clientRect.height < 1) continue;
// Assign the rectangle to whichever page its top edge falls inside. // Anchoring by the top edge (not requiring the rectangle to fit entirely) // keeps the rectangle that straddles a page break attached to the page // it visually belongs to. for (const page of pages) { const pageBounds = page.canvasWrapper.getBoundingClientRect();
if ( clientRect.top >= pageBounds.top && clientRect.top < pageBounds.bottom ) { rects.push({ left: clientRect.left - pageBounds.left, top: clientRect.top - pageBounds.top, width: clientRect.width, height: clientRect.height, pageNumber: page.number, }); break; } } }
return rects;}Step 4: Render highlights
Use the portal pattern (see rendering overlays with React portals) to render colored div elements for each location:
function TextAnnotation({ annotation, viewport, page }) { return ( <> {annotation.locations .filter((l) => l.page === page) .map((location, i) => { const pdfCoords = nodeLocationToScaled(location); const position = pdfToScreen(pdfCoords, viewport);
return ( <div key={i} style={{ position: "absolute", background: `rgba(255, 94, 94, 0.4)`, // semi-transparent highlight left: position.left, top: position.top, width: position.width, height: position.height, cursor: "pointer", }} /> ); })} </> );}Annotation data shape
Each text annotation is stored as:
{ type: "text", color: "red", // Highlight color. quote_content: "selected text", // The actual text content. locations: [ // Array of rectangles in PDF coordinates. { page: 1, rect: [72, 680, 540, 700] }, { page: 1, rect: [72, 660, 540, 680] }, // Multiple rectangles for multiline selections. ],}Supporting different annotation tools
Toggle between text highlight mode and free selection mode:
// In the pointerup handler:if (tool === "text") { // Instant create — select text and immediately create annotation. selection.empty(); selection.removeAllRanges(); onCreateAnnotation(color, locations, content);} else { // Popover mode — show a popover to let user choose to annotate. setPopoverAnchor({ left: clientLeft, top: clientTop }); setPendingLocations(locations); setPendingContent(content);}Cursor styles
Toggle cursor styles based on the active tool:
viewer.container.classList.toggle("highlightTextCursor", tool === "text");.highlightTextCursor .textLayer { cursor: text;}
.highlightTextCursor .textLayer ::selection { background: rgba(255, 94, 94, 0.3);}Key points
- Text selection tracking uses standard DOM APIs (
getSelection,getClientRects) on PDF.js’s text layer. - Convert client rectangles to page-relative coordinates by subtracting
canvasWrapper.getBoundingClientRect(). - Store annotations in PDF coordinate space (via
viewport.convertToPdfPoint). - A single text selection can span multiple lines, producing multiple location rectangles.
- Multipage selections are supported by iterating
getPagesFromRange.
FAQ
Browsers return one rectangle per visual line. A two-line selection produces two rectangles, a four-line selection produces four, and so on. range.getClientRects() walks the underlying text nodes and emits the bounding box of each rendered line fragment. Store all of them so the rendered highlight matches the user’s selection visually.
getPagesFromRange walks from range.startContainer’s page to range.endContainer’s page and returns every page in between. getClientRects then assigns each rectangle to the page its top edge falls inside, so the resulting annotation stores rectangles under the correct pageNumber. Render each annotation by filtering annotation.locations by page in your portal layer.
range.getClientRects() includes zero-width rectangles for the whitespace, newlines, and zero-width joiner characters in PDF.js’s text layer. They don’t represent visible text. Without the filter, you end up with phantom highlight slivers at the start or end of lines.
selectionchange fires on every keystroke and pointer move during selection, so you’d debounce it heavily to avoid reprocessing partial selections. pointerup fires once when the user finishes selecting, which is exactly when you want to capture the final range and create an annotation. For keyboard-driven selection, add a keyup fallback that runs the same handler.
Branch on a tool prop inside the pointerup handler. When tool === "text" (instant mode), call onCreateAnnotation immediately and clear the selection. When tool === "select" (default mode), open a popover anchored to the selection’s bounding rectangle and stash the pending locations in state — the user then chooses to annotate or dismiss.
Nutrient ships NutrientViewer.Annotations.HighlightAnnotation with built-in selection capture, multiline and multipage support, persistence to the PDF, and XFDF/JSON export. It requires no DOM traversal, no client-rectangle math, and no portal rendering. See the annotations guide and the migration guide.
How Nutrient Web SDK handles this
Nutrient provides a complete built-in text highlighting tool with no text selection tracking, client rectangle extraction, coordinate conversion, or React portal rendering required:
const annotation = new NutrientViewer.Annotations.HighlightAnnotation({ pageIndex: 0, rects: NutrientViewer.Immutable.List([ new NutrientViewer.Geometry.Rect({ left: 72, top: 680, width: 468, height: 20, }), ]), color: new NutrientViewer.Color({ r: 255, g: 234, b: 0 }),});await instance.create(annotation);The SDK includes 17+ annotation types, real-time collaboration, and XFDF export — all without custom coordinate math or DOM manipulation.
Nutrient Web SDK ships text highlighting built in — follow the migration guide to switch from PDF.js, or talk to Sales to discuss your requirements.