---
title: "How to build text highlight annotations with PDF.js and React"
canonical_url: "https://www.nutrient.io/blog/pdfjs-text-highlight-annotations/"
md_url: "https://www.nutrient.io/blog/pdfjs-text-highlight-annotations.md"
last_updated: "2026-06-23T20:12:24.806Z"
description: "Learn how to implement text highlight annotations on PDF.js by tracking text selection, converting client rectangles to PDF coordinates, and rendering colored overlays with React portals."
---

**TL;DR**

Text highlighting on PDF.js comes down to five steps:

- **Track selection** — Listen for `pointerup` on the viewer container and read `window.getSelection()`.

- **Identify pages** — Walk `range.startContainer` and `range.endContainer` to find which `.page` div elements the selection spans.

- **Extract rectangles** — Call `range.getClientRects()` and subtract `pageView.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](https://www.nutrient.io/blog/pdfjs-rendering-overlays-react-portals.md)).

This tutorial references several application-specific helpers: `screenToPdf`, `nodeLocationToScaled`, `pdfToScreen` (coordinate math — see [PDF.js coordinate systems](https://www.nutrient.io/blog/pdfjs-coordinate-systems-pdf-to-screen.md)), and `onCreateAnnotation`/`setPopoverAnchor`/`setPendingLocations`/`setPendingContent` (your own state setters). Treat them as integration points.

If you’d rather not build this, [Nutrient Web SDK](https://www.nutrient.io/sdk/web-overview/) 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 portals

```

## Step 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:

```tsx

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:

```tsx

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:

```tsx

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](https://www.nutrient.io/blog/pdfjs-rendering-overlays-react-portals.md)) to render colored div elements for each location:

```tsx

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:

```ts

{
  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:

```tsx

// 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:

```tsx

viewer.container.classList.toggle("highlightTextCursor", tool === "text");

```

```css.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

#### Why does a single text selection produce multiple rectangles?

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.

#### How do I handle multipage text selections?

`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.

#### Why filter out rectangles smaller than 1×1 pixel?

`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.

#### Why do I need `pointerup` instead of the `selectionchange` event?

`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.

#### How do I support different annotation tools (instant vs. popover)?

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.

#### How does Nutrient Web SDK compare for text highlights?

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](https://www.nutrient.io/guides/web/annotations/introduction-to-annotations.md) and the [migration guide](https://www.nutrient.io/guides/web/about/migration-guides/migrating-from-mozilla-pdfjs.md).

## 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:

```js

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](https://www.nutrient.io/sdk/web-overview/) ships text highlighting built in — follow the [migration guide](https://www.nutrient.io/guides/web/about/migration-guides/migrating-from-mozilla-pdfjs.md) to switch from PDF.js, or [talk to Sales](https://www.nutrient.io/contact-sales/?=sdk) to discuss your requirements._
---

## Related pages

- [Accessibility Untangled Why It Matters Guide](/blog/accessibility-untangled-why-it-matters-guide.md)
- [The business case for accessibility: Five ways it drives enterprise value](/blog/5-ways-accessibility-drives-enterprise-value.md)
- [Advanced Techniques For React Native Ui Components](/blog/advanced-techniques-for-react-native-ui-components.md)
- [The CEO’s AI playbook: Why decision architecture beats model selection](/blog/ceo-ai-playbook-decision-architecture.md)
- [Ai Document Automation Extraction To Action](/blog/ai-document-automation-extraction-to-action.md)
- [Convert One Drive Files To Pdf In Sharepoint](/blog/convert-one-drive-files-to-pdf-in-sharepoint.md)
- [Best Document Viewers](/blog/best-document-viewers.md)
- [Creating A Document Scanner With Ocr In Python](/blog/creating-a-document-scanner-with-ocr-in-python.md)
- [Complete Guide To Pdfjs](/blog/complete-guide-to-pdfjs.md)
- [Digital Workflow Automation](/blog/digital-workflow-automation.md)
- [Create Pdfs With React](/blog/create-pdfs-with-react.md)
- [The CTO’s AI playbook: Why accountability architecture beats orchestration](/blog/cto-ai-playbook-accountability-architecture.md)
- [Auto Tagging And Document Accessibility In Dotnet Sdk](/blog/auto-tagging-and-document-accessibility-in-dotnet-sdk.md)
- [Digital Signatures](/blog/digital-signatures.md)
- [Document Viewer](/blog/document-viewer.md)
- [Document Ai Vs Ocr](/blog/document-ai-vs-ocr.md)
- [Emerging threats: Your logging system may be an agentic threat vector](/blog/emerging-threats-your-logging-system.md)
- [How To Build A React Powerpoint Viewer](/blog/how-to-build-a-react-powerpoint-viewer.md)
- [or](/blog/how-to-build-a-javascript-pdf-viewer-with-pdfjs.md)
- [or](/blog/how-to-build-a-reactjs-pdf-viewer-with-react-pdf.md)
- [or](/blog/how-to-build-a-nextjs-pdf-viewer.md)
- [How To Build A Reactjs Viewer With Pdfjs](/blog/how-to-build-a-reactjs-viewer-with-pdfjs.md)
- [or](/blog/how-to-convert-html-to-pdf-using-react.md)
- [How To Convert Html To Pdf Using Html2pdf](/blog/how-to-convert-html-to-pdf-using-html2pdf.md)
- [or](/blog/how-to-convert-html-to-pdf-using-wkhtmltopdf-and-python.md)
- [How To Convert Word To Pdf In Nodejs](/blog/how-to-convert-word-to-pdf-in-nodejs.md)
- [How To Create Pdfs With React To Pdf](/blog/how-to-create-pdfs-with-react-to-pdf.md)
- [How To Embed A Pdf Viewer In Your Website](/blog/how-to-embed-a-pdf-viewer-in-your-website.md)
- [base_url tells WeasyPrint where to resolve relative asset paths](/blog/how-to-generate-pdf-reports-from-html-in-python.md)
- [How To Generate Pdf From Html With Nodejs](/blog/how-to-generate-pdf-from-html-with-nodejs.md)
- [Html In Pdf Format](/blog/html-in-pdf-format.md)
- [Open Pdf In Your Web App](/blog/open-pdf-in-your-web-app.md)
- [Linearized Pdf](/blog/linearized-pdf.md)
- [Online Document Viewer](/blog/online-document-viewer.md)
- [Nutrient Vs Conga Composer](/blog/nutrient-vs-conga-composer.md)
- [Pdf Sdk Performance Benchmark](/blog/pdf-sdk-performance-benchmark.md)
- [Pdfjs React Viewer Setup](/blog/pdfjs-react-viewer-setup.md)
- [Pdf Ua Compliance Guide](/blog/pdf-ua-compliance-guide.md)
- [Pdf Page Labels](/blog/pdf-page-labels.md)
- [Pdfjs Coordinate Systems Pdf To Screen](/blog/pdfjs-coordinate-systems-pdf-to-screen.md)
- [Pdfjs Navigation Zoom Rotation](/blog/pdfjs-navigation-zoom-rotation.md)
- [Pdfjs Text Search Pdffindcontroller](/blog/pdfjs-text-search-pdffindcontroller.md)
- [Pdf Sdk Compliance Security Checklist](/blog/pdf-sdk-compliance-security-checklist.md)
- [Pdfjs Limitations Commercial Upgrade](/blog/pdfjs-limitations-commercial-upgrade.md)
- [Pdfjs Eventbus Guide](/blog/pdfjs-eventbus-guide.md)
- [Pdfjs Rendering Overlays React Portals](/blog/pdfjs-rendering-overlays-react-portals.md)
- [Process Flows](/blog/process-flows.md)
- [or](/blog/sample-blog-updated.md)
- [Vector Pdf](/blog/vector-pdf.md)
- [Wcag2 Accessibility Requirements Documents](/blog/wcag2-accessibility-requirements-documents.md)
- [Convert an HTML file to PDF.](/blog/top-ten-ways-to-convert-html-to-pdf.md)
- [Web Sdk Is Now Headless](/blog/web-sdk-is-now-headless.md)
- [What Are Annotations](/blog/what-are-annotations.md)
- [What Is A Vpat](/blog/what-is-a-vpat.md)
- [Why Your Ai Agent Hallucinates Pdf Table Data](/blog/why-your-ai-agent-hallucinates-pdf-table-data.md)
- [Why Pdfium Is A Trusted Platform For Pdf Rendering](/blog/why-pdfium-is-a-trusted-platform-for-pdf-rendering.md)
- [What Is Pdf Ua](/blog/what-is-pdf-ua.md)

