EmbedPDF

Search

The search plugin finds text in the whole document and highlights every match. Register searchPlugin(), put <SearchLayer> in each page, and search from your own search box:

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchState } from '@embedpdf/react/search';
import { localEngine } from '@embedpdf/engine';

import './basic.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

function SearchBox() {
  const search = useSearch();
  const { hitCount, activeHitIndex, status } = useSearchState();
  const [text, setText] = useState('PDF');

  // Every change searches again, replacing the last search. An empty text clears it.
  useEffect(() => {
    void search.search({ text });
  }, [search, text]);

  let count = '';
  if (hitCount > 0) count = `${activeHitIndex + 1} of ${hitCount}`;
  else if (status === 'searching') count = 'Searching…';
  else if (status === 'complete') count = 'No matches';

  return (
    <div className="toolbar">
      <input
        className="field"
        type="search"
        aria-label="Search"
        placeholder="Search…"
        value={text}
        onChange={(event) => setText(event.target.value)}
        onKeyDown={(event) => {
          if (event.key !== 'Enter') return;
          if (event.shiftKey) search.previousHit();
          else search.nextHit();
        }}
      />
      <output className="readout">{count}</output>
      <button
        type="button"
        className="button"
        aria-label="Previous match"
        disabled={hitCount === 0}
        onClick={() => search.previousHit()}
      >
        ↑
      </button>
      <button
        type="button"
        className="button"
        aria-label="Next match"
        disabled={hitCount === 0}
        onClick={() => search.nextHit()}
      >
        ↓
      </button>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <SearchBox />
        <Stage className="stage">
          {() => (
            <>
              <RenderLayer />
              <SearchLayer />
            </>
          )}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

Try it: type a word, then press Enter for the next match and Shift+Enter for the one before. Each move scrolls the match into view.

search-box.tsx
import { RenderLayer } from '@embedpdf/react/render';
import { SearchLayer, useSearch, useSearchState } from '@embedpdf/react/search';
import { Stage } from '@embedpdf/react/stage';

export function SearchBox() {
  const search = useSearch();
  const { hitCount, activeHitIndex, status } = useSearchState();

  return (
    <form onSubmit={(event) => event.preventDefault()}>
      <input onChange={(event) => search.search({ text: event.target.value })} />
      {status === 'searching' && <span>Searching…</span>}
      {hitCount > 0 && (
        <span>
          {activeHitIndex + 1} of {hitCount}
        </span>
      )}
      <button type="button" onClick={() => search.previousHit()}>↑</button>
      <button type="button" onClick={() => search.nextHit()}>↓</button>
    </form>
  );
}

export function Pages() {
  return (
    <Stage>
      {() => (
        <>
          <RenderLayer />
          <SearchLayer />
        </>
      )}
    </Stage>
  );
}
  • Matches appear as they’re found. Large documents are searched a few pages at a time, and the count and the highlights grow as the search goes.
  • Searching again replaces the search. Type another letter and the old search stops; an empty text clears it.
  • The first match becomes the active one without moving the view. nextHit(), previousHit() and goToHit(hit) move to a match and bring it into view, and wrap around at the ends.

Search options#

search.search({ text: 'invoice total', wholeWord: true, matchCase: false });

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchState } from '@embedpdf/react/search';
import type { SearchQuery } from '@embedpdf/react/search';
import { localEngine } from '@embedpdf/engine';

import './options.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

function SearchWithOptions() {
  const search = useSearch();
  const hitCount = useSearchState((state) => state.hitCount);
  const [query, setQuery] = useState<SearchQuery>({
    text: 'pdf',
    matchCase: false,
    wholeWord: false,
  });

  // The text and the options are one query: changing either searches again.
  useEffect(() => {
    void search.search(query);
  }, [search, query]);

  return (
    <div className="toolbar">
      <input
        className="field"
        type="search"
        aria-label="Search"
        placeholder="Search…"
        value={query.text}
        onChange={(event) => setQuery({ ...query, text: event.target.value })}
      />
      <label className="option">
        <input
          type="checkbox"
          checked={query.matchCase}
          onChange={(event) => setQuery({ ...query, matchCase: event.target.checked })}
        />
        Match case
      </label>
      <label className="option">
        <input
          type="checkbox"
          checked={query.wholeWord}
          onChange={(event) => setQuery({ ...query, wholeWord: event.target.checked })}
        />
        Whole words
      </label>
      <output className="readout">{hitCount} matches</output>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <SearchWithOptions />
        <Stage className="stage">
          {() => (
            <>
              <RenderLayer />
              <SearchLayer />
            </>
          )}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

Try it: turn on Match case and pdf no longer finds PDF; turn on Whole words and it no longer finds EmbedPDF.

OptionWhat it doesDefault
textWhat to findrequired
matchCaseInvoice doesn’t match invoicefalse
wholeWordcat doesn’t match categoryfalse
matchDiacriticsresume doesn’t match résuméfalse
ignoreWhitespaceinvoice also finds i n v o i c e, for text with odd spacingfalse
regextext is a regular expressionfalse

A search starts from the page the reader is on, and { from: page.ref } starts it elsewhere.

A list of results#

useSearchHits() gives you every match found so far, each with a few words around it:

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  SearchLayer,
  searchPlugin,
  useSearch,
  useSearchHits,
  useSearchState,
} from '@embedpdf/react/search';
import { localEngine } from '@embedpdf/engine';

import './results-list.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

function SearchBox() {
  const search = useSearch();
  const hitCount = useSearchState((state) => state.hitCount);
  const [text, setText] = useState('PDF');

  useEffect(() => {
    void search.search({ text });
  }, [search, text]);

  return (
    <div className="toolbar">
      <input
        className="field"
        type="search"
        aria-label="Search"
        placeholder="Search…"
        value={text}
        onChange={(event) => setText(event.target.value)}
      />
      <output className="readout">{hitCount} matches</output>
    </div>
  );
}

// Every match with the words around it. Clicking one makes it the active match and scrolls to it.
function Results() {
  const search = useSearch();
  const hits = useSearchHits();
  const activeHitIndex = useSearchState((state) => state.activeHitIndex);

  return (
    <ol className="results">
      {hits.map((hit, index) => (
        <li key={`${hit.page.objectNumber}:${hit.start}`}>
          <button
            type="button"
            className="result"
            aria-current={index === activeHitIndex}
            onClick={() => search.goToHit(hit)}
          >
            <span className="result-page">Page {hit.pageIndex + 1}</span>
            {/* A match has no snippet when the user may search but not copy text. */}
            {hit.snippet && (
              <span className="result-text">
                …{hit.snippet.before}
                <mark>{hit.snippet.match}</mark>
                {hit.snippet.after}…
              </span>
            )}
          </button>
        </li>
      ))}
    </ol>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <SearchBox />
        <div className="viewer">
          <Stage className="stage">
            {() => (
              <>
                <RenderLayer />
                <SearchLayer />
              </>
            )}
          </Stage>
          <Results />
        </div>
      </DocumentGate>
    </Viewer>
  );
}
results.tsx
import { useSearch, useSearchHits } from '@embedpdf/react/search';

export function Results() {
  const search = useSearch();
  const hits = useSearchHits();

  return (
    <ol>
      {hits.map((hit, index) => (
        <li key={index}>
          <button type="button" onClick={() => search.goToHit(hit)}>
            Page {hit.pageIndex + 1}: …{hit.snippet?.before}
            <mark>{hit.snippet?.match}</mark>
            {hit.snippet?.after}…
          </button>
        </li>
      ))}
    </ol>
  );
}

useSearchHits(page.ref) gives the matches on one page, for a count per thumbnail.

FieldWhat it is
pageThe page it’s on
pageIndexThat page’s position, from 0
start, countThe characters it covers: a range you can select or highlight
boundsA box around it, in page coordinates
segmentsOne piece per line it covers, each with its four corners
snippetThe words before, the match and the words after

Highlight or select a match#

A match is a range of characters, the same kind a selection and a highlight use, so it goes straight into either:

const hit = search.getActiveHit();
 
selection.select(hit); // select it
await annotation.create(hit.page, {
  subtype: 'highlight',
  quadPoints: hit.segments.map((segment) => segment.quad),
}); // highlight it

Search without showing it#

findAll() searches the whole document and gives you the matches, without touching the search box’s results or the highlights:

const hits = await search.findAll({ text: 'confidential', wholeWord: true });

Use it for a “mark every occurrence” feature, or to redact every match.

Change how matches look#

Set the highlight colors when you register the plugin:

searchPlugin({
  highlight: { color: '#fde047', activeColor: '#f97316' },
});

On dark or scanned pages, where a multiplied highlight disappears, use translucent colors and blendMode: 'normal'. Each of these can also come from CSS, which wins over the setting, for example in dark mode:

@media (prefers-color-scheme: dark) {
  .pdf-viewer {
    --epdf-search-highlight: rgb(253 224 71 / 0.4);
    --epdf-search-blend-mode: normal;
  }
}

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchSettings } from '@embedpdf/react/search';
import { localEngine } from '@embedpdf/engine';

import './highlight-colors.css';

const engine = localEngine();
const plugins = [
  stagePlugin(),
  renderPlugin(),
  searchPlugin({ highlight: { color: '#ffd500', activeColor: '#ff9632' } }),
];

// Each pair: every match, and the active one.
const COLORS = [
  { name: 'Yellow', color: '#ffd500', activeColor: '#ff9632' },
  { name: 'Blue', color: '#a5d8ff', activeColor: '#4c9bff' },
  { name: 'Green', color: '#b2f2bb', activeColor: '#40c057' },
  { name: 'Pink', color: '#fcc2d7', activeColor: '#f06595' },
];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

function ColorPicker() {
  const search = useSearch();
  const current = useSearchSettings((settings) => settings.highlight.color);

  // Something to highlight.
  useEffect(() => {
    void search.search({ text: 'PDF' });
  }, [search]);

  return (
    <>
      {COLORS.map(({ name, color, activeColor }) => (
        <button
          key={name}
          type="button"
          className="swatch"
          aria-label={name}
          aria-pressed={current === color}
          style={{ background: `linear-gradient(135deg, ${color} 50%, ${activeColor} 50%)` }}
          onClick={() => search.updateSettings({ highlight: { color, activeColor } })}
        />
      ))}
      <button type="button" className="button" onClick={() => search.resetSettings()}>
        Reset
      </button>
    </>
  );
}

export default function App() {
  const [fromCss, setFromCss] = useState(false);

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <div className="toolbar">
          <ColorPicker />
          <label className="option">
            <input
              type="checkbox"
              checked={fromCss}
              onChange={(event) => setFromCss(event.target.checked)}
            />
            Override with CSS
          </label>
        </div>
        <Stage className={fromCss ? 'stage from-css' : 'stage'}>
          {() => (
            <>
              <RenderLayer />
              <SearchLayer />
            </>
          )}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

Try it: pick a color, and search.updateSettings({ highlight }) changes it at once. Then turn on Override with CSS: the CSS variables win over the color you picked, and their translucent highlights, drawn with blendMode: 'normal', stay visible on the dark cover.

Click a match on the page#

Give <SearchLayer onHitClick> a function, and it’s called with the match someone clicks. goToHit() takes that match as it is, so making it the active one is one line:

hit-click.tsx
import { SearchLayer, useSearch } from '@embedpdf/react/search';

export function PageMatches() {
  const search = useSearch();
  return <SearchLayer onHitClick={(hit) => search.goToHit(hit)} />;
}

Loading live preview…

import { useEffect } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchState } from '@embedpdf/react/search';
import { localEngine } from '@embedpdf/engine';

import './click-match.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// Which match is the active one: the first on load, then the one you click.
function ActiveMatch() {
  const search = useSearch();
  const { hitCount, activeHitIndex } = useSearchState();

  useEffect(() => {
    void search.search({ text: 'PDF' });
  }, [search]);

  return (
    <div className="toolbar">
      <output className="readout">
        {hitCount > 0 ? `Match ${activeHitIndex + 1} of ${hitCount}` : 'Searching…'}
      </output>
      <span className="hint">Click a highlighted match to make it the active one.</span>
    </div>
  );
}

function Pages() {
  const search = useSearch();

  return (
    <Stage className="stage">
      {() => (
        <>
          <RenderLayer />
          <SearchLayer onHitClick={(hit) => search.goToHit(hit)} />
        </>
      )}
    </Stage>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <ActiveMatch />
        <Pages />
      </DocumentGate>
    </Viewer>
  );
}

The text under a match stays selectable: a drag that starts on a match selects text, and only a click calls the function. Without one, matches are only paint, and the pointer goes straight through to the page.

Methods#

Everything useSearch() gives you.

MethodWhat it does
search(query, { from? })Starts a search, replacing the one before, and highlights the matches as they’re found. Resolves { status, hitCount } when it’s done, replaced or cancelled. An empty text clears. Search options
nextHit(reveal?), previousHit(reveal?)Makes the next or previous match the active one and scrolls to it, wrapping at the ends. Returns the match, or null when there is none.
goToHit(hit, reveal?)Makes one match the active one and scrolls to it: the match itself, or its place in the list. Click a match on the page
revealActiveHit(reveal?)Scrolls back to the active match without changing it
listHits({ page? })Every match found so far, or the ones on one page. In a component, use useSearchHits().
listPagesWithHits()The pages that have a match, in document order
cancel()Stops searching and keeps the matches found so far
clear()Stops searching and removes the matches and their highlights
refresh()Runs the current search again from the start. The plugin does this by itself when the document changes.
findAll(query, { snippets? })Finds every match without showing them or touching the current search. snippets: false leaves out the words around each match. Search without showing it
canSearch({ snippets? })Whether searching is allowed; with snippets: true, whether matches carry the words around them. Permissions
getSettings(), updateSettings(changes), resetSettings()Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use useSearchSettings(). Settings

reveal is { anchor, behavior }, the reveal setting for one call. Every method that returns a promise also takes a signal in its options, to cancel it.

State#

Everything useSearchState() gives you. Your component re-renders when a value changes. The method in the last column reads the same value once.

FieldWhat it isRead once
queryThe last search, with its options, or nullgetQuery()
status'idle', 'searching', 'complete', 'cancelled' or 'error'getStatus()
hitCountHow many matches were found so fargetHitCount(), or getHitCount(page) for one page
activeHitIndexThe active match’s place in the list, from 0, or -1getActiveHitIndex()
activeHitThe active match, or nullgetActiveHit()
progressHow far the search got: pagesSearched of pageCountgetProgress()
errorWhy the search failed, when status is 'error'getError()

The matches themselves come from useSearchHits(). Keeping them apart means a component that only shows the count doesn’t re-render for every match that arrives.

Settings#

Set them when you register the plugin, and change them while the app runs with search.updateSettings({ … }); search.resetSettings() goes back to what you registered, and useSearchSettings() gives you the current settings.

SettingWhat it doesDefaultFrom CSS
reveal.anchorWhere a match lands in the view when you move to it{ y: 0.35 }, like a browser—
reveal.behaviorGlide there ('smooth') or jump ('instant')'smooth'—
highlight.colorThe highlight of every match'#ffd500'--epdf-search-highlight
highlight.activeColorThe highlight of the active match'#ff9632'--epdf-search-highlight-active
highlight.blendModeHow the highlights mix with the page'multiply'--epdf-search-blend-mode

Events#

match-count.tsx
import { useState } from 'react';
import { useSearchEvent } from '@embedpdf/react/search';

export function MatchCount() {
  const [message, setMessage] = useState('');

  useSearchEvent(
    (search) => search.onCompleted,
    ({ hitCount }) => setMessage(`${hitCount} matches`),
  );

  return <p aria-live="polite">{message}</p>;
}
EventFires whenCarries
onStartedA search startedquery
onProgressChangedThe search’s progress changed: more pages were searched, or a new search started overhitCount, pagesSearched, pageCount
onCompletedThe whole document was searchedhitCount
onCancelledA search stopped before the endreason
onFailedA search failederror
onActiveHitChangedAnother match became the active oneindex, hit
onClearedThe search was cleared—
onSettingsChangedThe settings changedsettings, changed

Permissions#

PermissionWhat it allowsCheck
doc.text.searchSearchingsearch.canSearch()
doc.text.copyThe words around each match (snippet)search.canSearch({ snippets: true })

Without doc.text.copy, search still finds and highlights every match, but a match has no snippet.

Good to know#

Searches stay up to date#

When the document changes, for example when pages are inserted or text is redacted, the current search runs again, so the matches and the count stay right.

Searching large documents#

The local engine searches the file in the browser. Big documents are searched a few pages at a time, so the page stays responsive and results arrive as they’re found.

Was this page helpful?

Your feedback goes directly to the documentation team.