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

Table of contents

    How to implement text search in PDF.js with PDFFindController
    TL;DR

    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 call findController.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 updatefindmatchescount for running totals and updatefindcontrolstate for the final FindState (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);

    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

    ValuePurpose
    ""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

    1. You dispatch a find event on the EventBus
    2. PDFFindController receives it and searches the text layer of each page
    3. It dispatches updatefindmatchescount with running totals
    4. It dispatches updatefindcontrolstate with the final state
    5. It automatically scrolls to and highlights the current match
    6. Built-in CSS classes (.highlight, .highlight.selected) style the matches

    Key points

    • All search interaction goes through EventBus events, never direct method calls
    • Use type: "" for new searches, and type: "again" for next/previous navigation
    • The findPrevious Boolean controls search direction when using type: "again"
    • highlightAll: true highlights 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

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

    What’s the difference between 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.

    Why are there separate *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.

    Why do I get two events (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.

    How do I search across the entire document, not just visible pages?

    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.

    How does Nutrient Web SDK compare for search?

    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.

    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?