---
title: "Search — React"
description: "Find text in the document, highlight every match on the page, step through them, and show a list of results."
framework: "React"
source: "https://www.embedpdf.com/docs/headless/react/text/search"
---

# 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:

**`basic.tsx`**

```tsx
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>
  );
}
```

**`basic.css`**

```css
/* Colors, for light and dark */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

@media (prefers-color-scheme: dark) {
  :root {
    --ink: #e6ecfa;
    --muted: #93a3c7;
    --line: #27365f;
    --line-strong: #34467a;
    --surface: #131d3b;
    --tint: #1a2750;
    --canvas: #0b1328;
    --shadow: rgb(0 0 0 / 0.3);
  }
}

/* The search box, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.field {
  appearance: none;
  flex: 1 1 160px;
  min-width: 0;
  box-sizing: border-box;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 12px;
  box-shadow: 0 1px 2px var(--shadow);
}

.field::placeholder {
  color: var(--muted);
}

.field:focus-visible {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px rgb(125 182 255 / 0.35);
}

.readout {
  min-width: 84px;
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 15px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:active {
  transform: translateY(1px);
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

> **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`**

```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

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

**`options.tsx`**

```tsx
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>
  );
}
```

**`options.css`**

```css
/* Colors, for light and dark */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

@media (prefers-color-scheme: dark) {
  :root {
    --ink: #e6ecfa;
    --muted: #93a3c7;
    --line: #27365f;
    --line-strong: #34467a;
    --surface: #131d3b;
    --tint: #1a2750;
    --canvas: #0b1328;
    --accent: #7db6ff;
    --shadow: rgb(0 0 0 / 0.3);
  }
}

/* The search box and its options, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.field {
  appearance: none;
  flex: 1 1 140px;
  min-width: 0;
  box-sizing: border-box;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 12px;
  box-shadow: 0 1px 2px var(--shadow);
}

.field::placeholder {
  color: var(--muted);
}

.field:focus-visible {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px rgb(125 182 255 / 0.35);
}

/* An option is a checkbox in a pill that lights up while it's on */
.option {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 13px;
  font-weight: 550;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 7px 11px;
  cursor: pointer;
  user-select: none;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.option:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.option:has(input:checked) {
  color: var(--accent);
  background: var(--tint);
  border-color: var(--focus);
}

.option input {
  margin: 0;
  accent-color: #0876fd;
}

.readout {
  min-width: 84px;
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

> **Try it:** turn on **Match case** and `pdf` no longer finds `PDF`; turn on **Whole words** and it
> no longer finds `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:

**`results-list.tsx`**

```tsx
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-list.css`**

```css
/* Colors, for light and dark */
:root {
  --ink: #1a2748;
  --text: #3b4b7a;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #0876fd;
  --focus: #7db6ff;
  --mark: #ffd500;
  --shadow: rgb(7 32 76 / 0.04);
}

@media (prefers-color-scheme: dark) {
  :root {
    --ink: #e6ecfa;
    --text: #c3cde6;
    --muted: #93a3c7;
    --line: #27365f;
    --surface: #131d3b;
    --tint: #1a2750;
    --canvas: #0b1328;
    --accent: #7db6ff;
    --shadow: rgb(0 0 0 / 0.3);
  }
}

/* The search box, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.field {
  appearance: none;
  flex: 1 1 160px;
  min-width: 0;
  box-sizing: border-box;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 12px;
  box-shadow: 0 1px 2px var(--shadow);
}

.field::placeholder {
  color: var(--muted);
}

.field:focus-visible {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px rgb(125 182 255 / 0.35);
}

.readout {
  min-width: 84px;
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* The pages and the results: stacked, and side by side where there's room */
.viewer {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.stage,
.results {
  flex: 1 1 100%;
  box-sizing: border-box;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.stage {
  height: 340px;
  background: var(--canvas);
}

.results {
  max-height: 220px;
  overflow-y: auto;
  margin: 0;
  padding: 6px;
  list-style: none;
  background: var(--surface);
}

@container (min-width: 620px) {
  .stage {
    flex: 1 1 0;
    min-width: 0;
    height: 420px;
  }

  .results {
    flex: 0 0 240px;
    max-height: 420px;
  }
}

/* One result: its page, then the words around the match */
.result {
  display: flex;
  flex-direction: column;
  gap: 3px;
  width: 100%;
  font: inherit;
  text-align: left;
  color: var(--text);
  background: transparent;
  border: 0;
  border-radius: 9px;
  padding: 8px 10px;
  cursor: pointer;
}

.result:hover {
  background: var(--tint);
}

.result:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}

.result[aria-current='true'] {
  background: var(--tint);
  box-shadow: inset 3px 0 0 var(--accent);
}

.result-page {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.result-text {
  font-size: 13px;
  line-height: 1.45;
}

.result mark {
  color: #1a2748;
  background: var(--mark);
  border-radius: 3px;
  padding: 0 2px;
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

**`results.tsx`**

```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.

| 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:

```ts
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:

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

Use it for a "mark every occurrence" feature, or to [redact](../annotations/redaction) every match.

## Change how matches look

Set the highlight colors when you register the plugin:

```ts
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:

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

**`highlight-colors.tsx`**

```tsx
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>
  );
}
```

**`highlight-colors.css`**

```css
/* Colors, for light and dark */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

@media (prefers-color-scheme: dark) {
  :root {
    --ink: #e6ecfa;
    --muted: #93a3c7;
    --line: #27365f;
    --line-strong: #34467a;
    --surface: #131d3b;
    --tint: #1a2750;
    --canvas: #0b1328;
    --accent: #7db6ff;
    --shadow: rgb(0 0 0 / 0.3);
  }
}

/* The colors to pick from, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.swatch {
  width: 32px;
  height: 32px;
  border: 2px solid var(--surface);
  border-radius: 50%;
  padding: 0;
  cursor: pointer;
  box-shadow: 0 0 0 1px var(--line);
  transition: box-shadow 0.15s ease;
}

.swatch:hover {
  box-shadow: 0 0 0 1px var(--focus);
}

.swatch[aria-pressed='true'] {
  box-shadow: 0 0 0 2px var(--accent);
}

.swatch:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 9px 13px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* A checkbox in a pill that lights up while it's on */
.option {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  margin-left: auto;
  font-size: 13px;
  font-weight: 550;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 7px 11px;
  cursor: pointer;
  user-select: none;
  box-shadow: 0 1px 2px var(--shadow);
}

.option:has(input:checked) {
  color: var(--accent);
  background: var(--tint);
  border-color: var(--focus);
}

.option input {
  margin: 0;
  accent-color: #0876fd;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

/*
 * CSS wins over the setting. Translucent colors that don't multiply stay
 * visible on dark pages too, like the cover.
 */
.stage.from-css {
  --epdf-search-highlight: rgb(56 189 248 / 0.4);
  --epdf-search-highlight-active: rgb(236 72 153 / 0.55);
  --epdf-search-blend-mode: normal;
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

> **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`**

```tsx
import { SearchLayer, useSearch } from '@embedpdf/react/search';

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

**`click-match.tsx`**

```tsx
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>
  );
}
```

**`click-match.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --canvas: #f7f9fc;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The active match, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  margin-bottom: 14px;
}

.readout {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.hint {
  font-size: 13px;
  color: var(--muted);
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

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](#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](#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](#search-without-showing-it)                                 |
| `canSearch({ snippets? })`                                    | Whether searching is allowed; with `snippets: true`, whether matches carry the words around them. [Permissions](#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](#settings)                                                               |

`reveal` is `{ anchor, behavior }`, the [`reveal` setting](#settings) 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

**`match-count.tsx`**

```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>;
}
```

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

> **Search without handing out the text**
>
> With CloudPDF, the server searches the document. A user who may search but not copy gets where each
> match is, and nothing of the text around it, so the text of a view-only document never reaches the
> browser.
>
> [Search with CloudPDF](https://www.cloudpdf.com/docs/headless/react/text/search)
