---
title: "Search — Svelte"
description: "Find text in the document, highlight every match on the page, step through them, and show a list of results."
framework: "Svelte"
source: "https://www.embedpdf.com/docs/headless/svelte/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:

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { SearchLayer, searchPlugin } from '@embedpdf/svelte/search';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { localEngine } from '@embedpdf/engine';
  import SearchBox from './SearchBox.svelte';

  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()) };
  };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <SearchBox />
    <Stage class="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);
}
```

**`SearchBox.svelte`**

```svelte
<script lang="ts">
  import { useSearch, useSearchState } from '@embedpdf/svelte/search';

  const search = useSearch();
  const searchState = useSearchState();
  let text = $state('PDF');

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

  const count = $derived.by(() => {
    if (searchState.hitCount > 0)
      return `${searchState.activeHitIndex + 1} of ${searchState.hitCount}`;
    if (searchState.status === 'searching') return 'Searching…';
    if (searchState.status === 'complete') return 'No matches';
    return '';
  });

  function onKeyDown(event: KeyboardEvent) {
    if (event.key !== 'Enter') return;
    if (event.shiftKey) search.previousHit();
    else search.nextHit();
  }
</script>

<div class="toolbar">
  <input
    class="field"
    type="search"
    aria-label="Search"
    placeholder="Search…"
    bind:value={text}
    onkeydown={onKeyDown}
  />
  <output class="readout">{count}</output>
  <button
    type="button"
    class="button"
    aria-label="Previous match"
    disabled={searchState.hitCount === 0}
    onclick={() => search.previousHit()}
  >
    ↑
  </button>
  <button
    type="button"
    class="button"
    aria-label="Next match"
    disabled={searchState.hitCount === 0}
    onclick={() => search.nextHit()}
  >
    ↓
  </button>
</div>
```

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

```svelte
<script lang="ts">
  import { RenderLayer } from '@embedpdf/svelte/render';
  import { SearchLayer, useSearch, useSearchState } from '@embedpdf/svelte/search';
  import { Stage } from '@embedpdf/svelte/stage';

  const search = useSearch();
  const state = useSearchState();
</script>

<form onsubmit={(event) => event.preventDefault()}>
  <input oninput={(event) => search.search({ text: event.currentTarget.value })} />
  {#if state.status === 'searching'}
    <span>Searching…</span>
  {/if}
  {#if state.hitCount > 0}
    <span>{state.activeHitIndex + 1} of {state.hitCount}</span>
  {/if}
  <button type="button" onclick={() => search.previousHit()}>↑</button>
  <button type="button" onclick={() => search.nextHit()}>↓</button>
</form>

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

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { SearchLayer, searchPlugin } from '@embedpdf/svelte/search';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { localEngine } from '@embedpdf/engine';
  import SearchWithOptions from './SearchWithOptions.svelte';

  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()) };
  };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <SearchWithOptions />
    <Stage class="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);
}
```

**`SearchWithOptions.svelte`**

```svelte
<script lang="ts">
  import { useSearch, useSearchState, type SearchQuery } from '@embedpdf/svelte/search';

  const search = useSearch();
  const hitCount = useSearchState((state) => state.hitCount);
  const query = $state<SearchQuery>({ text: 'pdf', matchCase: false, wholeWord: false });

  // The text and the options are one query: changing either searches again. The copy reads every
  // field, so the effect follows them all.
  $effect(() => {
    void search.search({ ...query });
  });
</script>

<div class="toolbar">
  <input
    class="field"
    type="search"
    aria-label="Search"
    placeholder="Search…"
    bind:value={query.text}
  />
  <label class="option">
    <input type="checkbox" bind:checked={query.matchCase} />
    Match case
  </label>
  <label class="option">
    <input type="checkbox" bind:checked={query.wholeWord} />
    Whole words
  </label>
  <output class="readout">{hitCount.current} matches</output>
</div>
```

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

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { SearchLayer, searchPlugin } from '@embedpdf/svelte/search';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { localEngine } from '@embedpdf/engine';
  import Results from './Results.svelte';
  import SearchBox from './SearchBox.svelte';

  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()) };
  };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <SearchBox />
    <div class="viewer">
      <Stage class="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.svelte`**

```svelte
<!-- Every match with the words around it. Clicking one makes it the active match and scrolls to it. -->
<script lang="ts">
  import { useSearch, useSearchHits, useSearchState } from '@embedpdf/svelte/search';

  const search = useSearch();
  const hits = useSearchHits();
  const activeHitIndex = useSearchState((state) => state.activeHitIndex);
</script>

<ol class="results">
  {#each hits.current as hit, index (`${hit.page.objectNumber}:${hit.start}`)}
    <li>
      <button
        type="button"
        class="result"
        aria-current={index === activeHitIndex.current}
        onclick={() => search.goToHit(hit)}
      >
        <span class="result-page">Page {hit.pageIndex + 1}</span>
        <!-- A match has no snippet when the user may search but not copy text. -->
        {#if hit.snippet}
          <span class="result-text">
            …{hit.snippet.before}<mark>{hit.snippet.match}</mark>{hit.snippet.after}…
          </span>
        {/if}
      </button>
    </li>
  {/each}
</ol>
```

**`SearchBox.svelte`**

```svelte
<script lang="ts">
  import { useSearch, useSearchState } from '@embedpdf/svelte/search';

  const search = useSearch();
  const hitCount = useSearchState((state) => state.hitCount);
  let text = $state('PDF');

  $effect(() => {
    void search.search({ text });
  });
</script>

<div class="toolbar">
  <input class="field" type="search" aria-label="Search" placeholder="Search…" bind:value={text} />
  <output class="readout">{hitCount.current} matches</output>
</div>
```

**`results.svelte`**

```svelte
<script lang="ts">
  import { useSearch, useSearchHits } from '@embedpdf/svelte/search';

  const search = useSearch();
  const hits = useSearchHits();
</script>

<ol>
  {#each hits.current as hit, index (index)}
    <li>
      <button type="button" onclick={() => search.goToHit(hit)}>
        Page {hit.pageIndex + 1}: …{hit.snippet?.before}
        <mark>{hit.snippet?.match}</mark>
        {hit.snippet?.after}…
      </button>
    </li>
  {/each}
</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;
  }
}
```

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { RenderLayer, renderPlugin } from '@embedpdf/svelte/render';
  import { SearchLayer, searchPlugin } from '@embedpdf/svelte/search';
  import { Stage, stagePlugin } from '@embedpdf/svelte/stage';
  import { localEngine } from '@embedpdf/engine';
  import ColorPicker from './ColorPicker.svelte';

  import '../highlight-colors.css';

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

  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()) };
  };
</script>

<script lang="ts">
  let fromCss = $state(false);
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <div class="toolbar">
      <ColorPicker />
      <label class="option">
        <input type="checkbox" bind:checked={fromCss} />
        Override with CSS
      </label>
    </div>
    <Stage class={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);
}
```

**`ColorPicker.svelte`**

```svelte
<script lang="ts">
  import { useSearch, useSearchSettings } from '@embedpdf/svelte/search';

  // 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 search = useSearch();
  const selected = useSearchSettings((settings) => settings.highlight.color);

  // Something to highlight.
  $effect(() => {
    void search.search({ text: 'PDF' });
  });
</script>

{#each COLORS as { name, color, activeColor } (name)}
  <button
    type="button"
    class="swatch"
    aria-label={name}
    aria-pressed={selected.current === color}
    style:background="linear-gradient(135deg, {color} 50%, {activeColor} 50%)"
    onclick={() => search.updateSettings({ highlight: { color, activeColor } })}
  ></button>
{/each}
<button type="button" class="button" onclick={() => search.resetSettings()}>Reset</button>
```

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

```svelte
<script lang="ts">
  import { SearchLayer, useSearch } from '@embedpdf/svelte/search';

  const search = useSearch();
</script>

<SearchLayer onHitClick={(hit) => search.goToHit(hit)} />
```

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { renderPlugin } from '@embedpdf/svelte/render';
  import { searchPlugin } from '@embedpdf/svelte/search';
  import { stagePlugin } from '@embedpdf/svelte/stage';
  import { localEngine } from '@embedpdf/engine';
  import ActiveMatch from './ActiveMatch.svelte';
  import Pages from './Pages.svelte';

  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()) };
  };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <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);
}
```

**`ActiveMatch.svelte`**

```svelte
<!-- Which match is the active one: the first on load, then the one you click. -->
<script lang="ts">
  import { useSearch, useSearchState } from '@embedpdf/svelte/search';

  const search = useSearch();
  const state = useSearchState();

  $effect(() => {
    void search.search({ text: 'PDF' });
  });
</script>

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

**`Pages.svelte`**

```svelte
<script lang="ts">
  import { RenderLayer } from '@embedpdf/svelte/render';
  import { SearchLayer, useSearch } from '@embedpdf/svelte/search';
  import { Stage } from '@embedpdf/svelte/stage';

  const search = useSearch();
</script>

<Stage class="stage">
  <RenderLayer />
  <SearchLayer onHitClick={(hit) => search.goToHit(hit)} />
</Stage>
```

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, as one reactive object: read its fields, and your markup updates when they change. 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()`.

## 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.svelte`**

```svelte
<script lang="ts">
  import { useSearchEvent } from '@embedpdf/svelte/search';

  let message = $state('');

  useSearchEvent(
    (search) => search.onCompleted,
    ({ hitCount }) => (message = `${hitCount} matches`),
  );
</script>

<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/svelte/text/search)
