This HTML page is not optimized for LLM or AI agent consumption. Fetch the Markdown version instead: /blog/pdfjs-text-highlight-annotations.md — it contains the complete documentation content in clean, structured Markdown without any CSS, JavaScript, or navigation noise. How to build text highlight annotations with PDF.js and React

Table of contents

    Text highlighting is the most common PDF annotation. This guide covers how to track text selection on PDF.js pages, convert browser selection rectangles to PDF coordinates, and render persistent highlight overlays using React.
    How to build text highlight annotations with PDF.js and React
    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).

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

    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

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

    Austin Nguyen

    Austin Nguyen

    AI Engineer

    When Austin isn’t pulling all-nighters to build new features, he enjoys watching science videos on YouTube and cooking.

    Explore related topics

    Try for free Ready to get started?