react-pdf text layer: Selection, search, and redaction
Table of contents
react-pdf text layer — enabling text selection and copy-paste, building custom text renderers for search highlighting and visual masking, and extracting raw text content from PDF pages.
- The text layer is enabled by default — import
react-pdf/dist/Page/TextLayer.cssand you get native selection, copy-paste, and Control-F search for free. customTextRenderer({ str, itemIndex })lets you wrap or replace text per item. Its return value is inserted as HTML, so escape any untrusted input before concatenating it into tags.onGetTextSuccess({ items, styles })gives you raw text and font metadata — pair it withrenderMode="none"andrenderTextLayer={false}for headless extraction.customTextRendererproduces visual masking, not real redaction. The underlying text stays in the source PDF and inonGetTextSuccess. For true redaction, modify the PDF bytes (e.g. withpdf-lib) or use an SDK with a redaction API.
The text layer is an invisible HTML overlay on top of the canvas that enables text selection, copy-paste, and accessibility. react-pdf also supports a custom text renderer for highlighting or transforming text.
Security note: customTextRenderer returns a string that react-pdf injects as HTML, not text. If the PDF (or any user-controlled input you concatenate into the return value) contains characters like <, >, or &, the browser will parse them as markup. Always HTML-escape str and any user input before wrapping it in tags — the examples below use an escapeHtml helper for this.
Enabling the text layer
The text layer is enabled by default. Make sure you import the CSS:
import "react-pdf/dist/Page/TextLayer.css";<Page pageNumber={1} renderTextLayer={true} /> {/* default */}To disable the text layer, set renderTextLayer to false:
<Page pageNumber={1} renderTextLayer={false} />What the text layer does
- Overlays invisible
<span>elements on top of the canvas, positioned to match the rendered text - Enables native text selection (click and drag)
- Enables copy-paste (Control-C/Command-C)
- Enables the browser’s built-in find (Control-F/Command-F)
- Provides accessibility for screen readers
Accessing text content
Use the onGetTextSuccess callback to access the raw text data:
<Page pageNumber={1} onGetTextSuccess={({ items, styles }) => { // `items` is an array of text items. items.forEach((item) => { console.log(item.str); // The text string. console.log(item.dir); // Text direction ("ltr" or "rtl"). console.log(item.width); // Width in PDF points. console.log(item.height); // Height in PDF points. console.log(item.transform); // Position/rotation matrix. console.log(item.hasEOL); // Followed by line break? });
// `styles` contains font information keyed by font name. console.log(styles); }}/>Custom text renderer
The customTextRenderer prop lets you modify how text items are rendered. It receives each text item and returns a string (which can contain HTML):
<Page pageNumber={1} customTextRenderer={({ str, itemIndex }) => { // `str`: the text string for this item. // `itemIndex`: index of the item in the text content array. return str; }}/>Example: Highlighting search terms
This component wraps every match for searchText in a <mark> tag so it renders highlighted in the text layer:
function HighlightedPage({ pageNumber, searchText }) { const customTextRenderer = useCallback( ({ str }) => { const escaped = escapeHtml(str); if (!searchText) return escaped;
const regex = new RegExp(`(${escapeRegex(searchText)})`, "gi"); // `$1` is the matched substring from `escaped`, so it's already HTML-safe. return escaped.replace(regex, '<mark class="highlight">$1</mark>'); }, [searchText], );
return ( <Page pageNumber={pageNumber} customTextRenderer={customTextRenderer} /> );}
function escapeHtml(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[c]);}
function escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");}Note that escapeRegex covers regex metacharacters and escapeHtml covers HTML special characters — you need both. escapeRegex alone leaves you open to HTML injection if the PDF text contains tags.
.highlight { background-color: yellow; color: black; border-radius: 2px; padding: 0 1px;}Example: Visually masking sensitive content
This is visual masking, not redaction. customTextRenderer only changes how text appears in the rendered text layer — the original text remains in the PDF file, in onGetTextSuccess, and in any download or print of the original document. For true redaction, you need to rewrite the PDF bytes (e.g. with pdf-lib(opens in a new tab) or Nutrient Web SDK’s redaction API).
function MaskedPage({ pageNumber, maskPatterns }) { const customTextRenderer = useCallback( ({ str }) => { let result = escapeHtml(str); for (const pattern of maskPatterns) { // Recreate the pattern with the same flags so it operates on the // HTML-escaped string. Patterns should target characters that // can't be affected by HTML escaping (digits, letters, etc.). result = result.replace(pattern, (match) => `<span class="redacted">${"\u2588".repeat(match.length)}</span>`, ); } return result; }, [maskPatterns], );
return ( <Page pageNumber={pageNumber} customTextRenderer={customTextRenderer} /> );}Example: Adding tooltips
This renderer wraps any email address it finds in a <span> with a tooltip:
const customTextRenderer = ({ str }) => { // Escape first, then wrap. The capture group `$1` is safe because the // email regex only matches characters that survive HTML escaping unchanged. return escapeHtml(str).replace( /(\S+@\S+\.\S+)/g, '<span title="Click to email" class="email-link">$1</span>', );};Extracting all text from a PDF
This component mounts every page with rendering disabled and collects each page’s text as onGetTextSuccess fires:
function TextExtractor({ file }) { const [allText, setAllText] = useState([]);
const handleTextSuccess = useCallback( (pageNumber) => ({ items }) => { const pageText = items.map((item) => item.str).join(" "); setAllText((prev) => { const updated = [...prev]; updated[pageNumber - 1] = pageText; return updated; }); }, [], );
return ( <Document file={file} onLoadSuccess={({ numPages }) => setAllText(new Array(numPages).fill(""))}> {allText.map((_, i) => ( <Page key={i} pageNumber={i + 1} onGetTextSuccess={handleTextSuccess(i + 1)} renderMode="none" // Don't render canvas. renderTextLayer={false} // Don't render text layer DOM. /> ))} </Document> );}Text layer callbacks
Page also exposes success and error callbacks for both text extraction and text layer rendering:
<Page pageNumber={1} onGetTextSuccess={({ items, styles }) => { // Text content extracted from page. }} onGetTextError={(error) => { // Failed to extract text. }} onRenderTextLayerSuccess={() => { // Text layer DOM rendered. }} onRenderTextLayerError={(error) => { // Failed to render text layer. }}/>Key points
- Import
react-pdf/dist/Page/TextLayer.cssor text selection won’t work. renderTextLayeristrueby default.customTextRendererreceives{ str, itemIndex }and returns a string (can include HTML).- Use
customTextRendererfor search highlighting, redaction, or text transformation. onGetTextSuccessgives you raw text data for extraction/indexing.renderMode="none"withrenderTextLayer={false}is useful for text-only extraction without visual rendering.
How Nutrient Web SDK handles this
Instead of building custom text renderers with regex replacement and manual CSS, Nutrient Web SDK provides text selection and search out of the box:
// Built-in text selection + full-featured search.const results = await instance.search("search term");instance.setSearchState((state) => state.set("results", results));
// Text extraction via API.const pageText = await instance.textLinesForPageIndex(0);There’s no customTextRenderer with regex replacement, and no manual CSS for highlights. Nutrient provides a full-featured search UI with match counts, result navigation, case sensitivity, and whole-word matching — plus programmatic text extraction without rendering.
Learn more about Nutrient Web SDK | Migration guide | Contact Sales
FAQ
You’re probably missing the text layer stylesheet. Import react-pdf/dist/Page/TextLayer.css before rendering any <Page>. Without it, the invisible text spans don’t position correctly over the canvas and selections render in the wrong place or invisibly.
customTextRenderer safe against XSS?Only if you escape its return value — otherwise it’s vulnerable to cross-site scripting (XSS). react-pdf injects the returned string as HTML, so any unescaped <, >, or & in the PDF text (or in user input you concatenate in) will be parsed as markup. Use the escapeHtml helper shown above before adding tags.
customTextRenderer to redact sensitive data?Not safely. It only changes how text renders — the original text remains in the source PDF, in onGetTextSuccess, and in any export of the file. For real redaction, rewrite the PDF bytes with a library like pdf-lib, or use an SDK that has a dedicated redaction API.
Mount each <Page> with renderMode="none" and renderTextLayer={false}, and listen for onGetTextSuccess. PDF.js still loads the page object and runs text extraction, but it skips the canvas paint and DOM text layer, resulting in much lower memory and CPU usage for headless extraction.
customTextRenderer rerun on every render?It reruns whenever the function identity changes. Wrap it in useCallback keyed on the values it closes over (search term, redact patterns, etc.). Otherwise, react-pdf rerenders the text layer on every parent render, which is wasteful and can cause flicker.
styles object from onGetTextSuccess?styles is a map keyed by font name with per-font metadata, including fontFamily, ascent, descent, and vertical. PDF.js uses this internally to position text spans; you can use it for things like font analysis or matching PDF styles in your own UI overlays.