How to implement text search in PDF.js with PDFFindController
Table of contents
PDF.js’s PDFFindController handles full-document text search. You control it entirely through EventBus events — never direct method calls. The wiring has three parts:
- Setup — Construct
PDFFindController({ linkService, eventBus })and callfindController.setDocument(pdfDocument)after the document loads. - Dispatch — Call
eventBus.dispatch("find", { type, query, caseSensitive, entireWord, highlightAll, matchDiacritics, findPrevious })to trigger or update a search.type: ""starts a new search;type: "again"walks to the next or previous match. - Listen — Subscribe to
updatefindmatchescountfor running totals andupdatefindcontrolstatefor the finalFindState(FOUND,NOT_FOUND,PENDING).
The controller scrolls to and highlights the active match for you. If you’d rather skip the EventBus dance, Nutrient Web SDK exposes instance.search() and a built-in search UI.
PDF.js includes a built-in PDFFindController that handles text search across all pages. This guide shows how to wire it up with a custom React search UI, handle match counts, and navigate between results.
Setup
The PDFFindController is created during viewer initialization and needs both the EventBus and PDFLinkService:
const pdfjs = await import("pdfjs-dist/web/pdf_viewer.mjs");
const eventBus = new pdfjs.EventBus();const linkService = new pdfjs.PDFLinkService({ eventBus });const findController = new pdfjs.PDFFindController({ linkService, eventBus });
// After document loads:findController.setDocument(pdfDocument);Triggering a search
Search is controlled entirely through EventBus find events. You never call methods on the find controller directly — you dispatch events:
eventBus.dispatch("find", { type: "", // "" for new search, "again" for next/prev query: "neural network", caseSensitive: false, entireWord: false, highlightAll: true, matchDiacritics: false, findPrevious: false, // true = search backwards});The type parameter
| Value | Purpose |
|---|---|
"" | Start a new search |
"again" | Navigate to next/previous result |
"casesensitivitychange" | Re-search with changed case sensitivity |
"entirewordchange" | Re-search with changed whole-word setting |
"highlightallchange" | Toggle highlight-all |
"diacriticmatchingchange" | Toggle diacritics matching |
Listening for results
Subscribe to two events to track search state:
// Match count updates.eventBus.on("updatefindmatchescount", (evt) => { console.log(`Result ${evt.matchesCount.current} of ${evt.matchesCount.total}`);});
// Find state changes.eventBus.on("updatefindcontrolstate", (evt) => { // evt.state is a FindState enum value // evt.matchesCount = { current, total } switch (evt.state) { case pdfjs.FindState.FOUND: // Match found. break; case pdfjs.FindState.NOT_FOUND: // No matches. break; case pdfjs.FindState.PENDING: // Still searching... break; }});Complete React search component
import { useCallback, useContext, useEffect, useRef, useState } from "react";import { FindState } from "pdfjs-dist/web/pdf_viewer.mjs";import { PDFContext } from "./PDFContext";
function SearchToolbar() { const { eventBus } = useContext(PDFContext); const [query, setQuery] = useState(""); const [matchCount, setMatchCount] = useState({ current: 0, total: 0 }); const [findState, setFindState] = useState(null);
// Search options. const [caseSensitive, setCaseSensitive] = useState(false); const [entireWord, setEntireWord] = useState(false); const [highlightAll, setHighlightAll] = useState(false); const [matchDiacritics, setMatchDiacritics] = useState(false);
// Track previous values to determine which option changed. const prevQuery = useRef(""); const prevCaseSensitive = useRef(false); const prevEntireWord = useRef(false); const prevHighlightAll = useRef(false); const prevMatchDiacritics = useRef(false);
const dispatchFind = useCallback( (type, findPrevious = false) => { eventBus.current?.dispatch("find", { type, query, caseSensitive, entireWord, highlightAll, matchDiacritics, findPrevious, }); }, [eventBus, query, caseSensitive, entireWord, highlightAll, matchDiacritics], );
// Dispatch appropriate event when any search parameter changes. useEffect(() => { if (query !== prevQuery.current) { dispatchFind(""); } else if (caseSensitive !== prevCaseSensitive.current) { dispatchFind("casesensitivitychange"); } else if (entireWord !== prevEntireWord.current) { dispatchFind("entirewordchange"); } else if (highlightAll !== prevHighlightAll.current) { dispatchFind("highlightallchange"); } else if (matchDiacritics !== prevMatchDiacritics.current) { dispatchFind("diacriticmatchingchange"); }
prevQuery.current = query; prevCaseSensitive.current = caseSensitive; prevEntireWord.current = entireWord; prevHighlightAll.current = highlightAll; prevMatchDiacritics.current = matchDiacritics; }, [query, caseSensitive, entireWord, highlightAll, matchDiacritics, dispatchFind]);
// Listen for results. useEffect(() => { const onMatchCount = (evt) => setMatchCount(evt.matchesCount); const onState = (evt) => { setFindState(evt.state); setMatchCount(evt.matchesCount); };
eventBus.current?.on("updatefindmatchescount", onMatchCount); eventBus.current?.on("updatefindcontrolstate", onState);
return () => { eventBus.current?.off("updatefindmatchescount", onMatchCount); eventBus.current?.off("updatefindcontrolstate", onState); }; }, [eventBus]);
return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> <span> {findState === FindState.NOT_FOUND ? "No results" : `${matchCount.current} of ${matchCount.total}`} </span> <button onClick={() => dispatchFind("again", true)}>Previous</button> <button onClick={() => dispatchFind("again", false)}>Next</button> <label> <input type="checkbox" checked={caseSensitive} onChange={() => setCaseSensitive(!caseSensitive)} /> Case sensitive </label> <label> <input type="checkbox" checked={entireWord} onChange={() => setEntireWord(!entireWord)} /> Whole word </label> <label> <input type="checkbox" checked={highlightAll} onChange={() => setHighlightAll(!highlightAll)} /> Highlight all </label> </div> );}How it works under the hood
- You dispatch a
findevent on theEventBus PDFFindControllerreceives it and searches the text layer of each page- It dispatches
updatefindmatchescountwith running totals - It dispatches
updatefindcontrolstatewith the final state - It automatically scrolls to and highlights the current match
- Built-in CSS classes (
.highlight,.highlight.selected) style the matches
Key points
- All search interaction goes through
EventBusevents, never direct method calls - Use
type: ""for new searches, andtype: "again"for next/previous navigation - The
findPreviousBoolean controls search direction when usingtype: "again" highlightAll: truehighlights all matches on visible pages, not just the current one- Track previous option values to dispatch the correct change event type
- The find controller handles page-by-page searching automatically — no manual page iteration needed
FAQ
PDFFindController use events instead of method calls?PDF.js was designed around its viewer layer, which is fully event-driven. The find controller listens for find events on the EventBus, so the same code path serves the built-in PDF.js toolbar, your custom React UI, and any other client. Calling methods directly would skip the controller’s internal queuing and result tracking, which is why PDF.js doesn’t expose them.
type: "" and type: "again"?type: "" starts a fresh search — the controller rebuilds its internal match list from the current query and options. type: "again" navigates within the existing match list using the findPrevious flag (false for next, true for previous). If you dispatch "again" without first running a "" search, it’s a no-op.
*change event types for each option?PDF.js needs to know which option changed so it can decide whether to rerun the full search or just refilter existing matches. Toggling highlightAll only redraws — no re-search needed — while changing caseSensitive invalidates the match list and forces a rerun. The granular type values let the controller pick the cheapest path.
updatefindmatchescount and updatefindcontrolstate)?The controller searches asynchronously. updatefindmatchescount fires repeatedly as new pages finish indexing, so you can show a running total. updatefindcontrolstate fires once with the final FindState (FOUND, NOT_FOUND, PENDING, or WRAPPED) and the definitive match count. Most UIs listen to both — totals to the count event, state to the control event.
That’s the default. PDFFindController walks every page’s text content, not just the rendered ones. You don’t need to scroll the viewer or preload pages. The catch: If you want match counts to update incrementally, leave highlightAll: true so PDF.js paints found matches as soon as a page is indexed.
Nutrient exposes instance.search(query) returning an array of matches with page indexes and bounding rectangles. Combined with instance.setSearchState(), you can drive the built-in search UI directly without managing EventBus state. The SDK also indexes annotations and form fields, so searches return hits that PDF.js’s text-layer search would miss.
How Nutrient Web SDK handles this
Two lines replace the entire PDFFindController setup, EventBus dispatch, and result listener wiring shown above:
// Search across all pages.const results = await instance.search("neural network");
// Display the results in the built-in search UI.instance.setSearchState((state) => state.set("results", results));The built-in search UI also provides match counts, case sensitivity, and result navigation.
See Nutrient Web SDK for a built-in search API and UI, or follow the migration guide to switch from PDF.js. Talk to Sales about your requirements.