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.
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
textclears it. - The first match becomes the active one without moving the view.
nextHit(),previousHit()andgoToHit(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
EmbedPDF.
| Option | What it does | Default |
|---|---|---|
text | What to find | required |
matchCase | Invoice doesn’t match invoice | false |
wholeWord | cat doesn’t match category | false |
matchDiacritics | resume doesn’t match résumé | false |
ignoreWhitespace | invoice also finds i n v o i c e, for text with odd spacing | false |
regex | text is a regular expression | false |
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>
);
}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.
| Field | What it is |
|---|---|
page | The page it’s on |
pageIndex | That page’s position, from 0 |
start, count | The characters it covers: a range you can select or highlight |
bounds | A box around it, in page coordinates |
segments | One piece per line it covers, each with its four corners |
snippet | The 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 itSearch 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 withblendMode: '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:
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.
| Method | What 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.
| Field | What it is | Read once |
|---|---|---|
query | The last search, with its options, or null | getQuery() |
status | 'idle', 'searching', 'complete', 'cancelled' or 'error' | getStatus() |
hitCount | How many matches were found so far | getHitCount(), or getHitCount(page) for one page |
activeHitIndex | The active match’s place in the list, from 0, or -1 | getActiveHitIndex() |
activeHit | The active match, or null | getActiveHit() |
progress | How far the search got: pagesSearched of pageCount | getProgress() |
error | Why 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.
| Setting | What it does | Default | From CSS |
|---|---|---|---|
reveal.anchor | Where a match lands in the view when you move to it | { y: 0.35 }, like a browser | — |
reveal.behavior | Glide there ('smooth') or jump ('instant') | 'smooth' | — |
highlight.color | The highlight of every match | '#ffd500' | --epdf-search-highlight |
highlight.activeColor | The highlight of the active match | '#ff9632' | --epdf-search-highlight-active |
highlight.blendMode | How the highlights mix with the page | 'multiply' | --epdf-search-blend-mode |
Events#
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>;
}| Event | Fires when | Carries |
|---|---|---|
onStarted | A search started | query |
onProgressChanged | The search’s progress changed: more pages were searched, or a new search started over | hitCount, pagesSearched, pageCount |
onCompleted | The whole document was searched | hitCount |
onCancelled | A search stopped before the end | reason |
onFailed | A search failed | error |
onActiveHitChanged | Another match became the active one | index, hit |
onCleared | The search was cleared | — |
onSettingsChanged | The settings changed | settings, changed |
Permissions#
| Permission | What it allows | Check |
|---|---|---|
doc.text.search | Searching | search.canSearch() |
doc.text.copy | The 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.
Your feedback goes directly to the documentation team.