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

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

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>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <SearchBox />
      <Stage class="stage">
        <template #page>
          <RenderLayer />
          <SearchLayer />
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

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

```vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useSearch, useSearchState } from '@embedpdf/vue/search';

const search = useSearch();
const { hitCount, activeHitIndex, status } = useSearchState();
const text = ref('PDF');

// Every change searches again, replacing the last search. An empty text clears it.
watch(text, (value) => void search.search({ text: value }), { immediate: true });

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

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

<template>
  <div class="toolbar">
    <input
      v-model="text"
      class="field"
      type="search"
      aria-label="Search"
      placeholder="Search…"
      @keydown="onKeydown"
    />
    <output class="readout">{{ count }}</output>
    <button
      type="button"
      class="button"
      aria-label="Previous match"
      :disabled="hitCount === 0"
      @click="search.previousHit()"
    >
      ↑
    </button>
    <button
      type="button"
      class="button"
      aria-label="Next match"
      :disabled="hitCount === 0"
      @click="search.nextHit()"
    >
      ↓
    </button>
  </div>
</template>
```

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

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

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

function onInput(event: Event) {
  search.search({ text: (event.target as HTMLInputElement).value });
}
</script>

<template>
  <form @submit.prevent>
    <input @input="onInput" />
    <span v-if="status === 'searching'">Searching…</span>
    <span v-if="hitCount > 0">{{ activeHitIndex + 1 }} of {{ hitCount }}</span>
    <button type="button" @click="search.previousHit()">↑</button>
    <button type="button" @click="search.nextHit()">↓</button>
  </form>

  <Stage>
    <template #page>
      <RenderLayer />
      <SearchLayer />
    </template>
  </Stage>
</template>
```

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

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

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>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <SearchWithOptions />
      <Stage class="stage">
        <template #page>
          <RenderLayer />
          <SearchLayer />
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

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

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

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

// The text and the options are one query: changing either searches again.
watch(query, (value) => void search.search({ ...value }), { deep: true, immediate: true });
</script>

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

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

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

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>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <SearchBox />
      <div class="viewer">
        <Stage class="stage">
          <template #page>
            <RenderLayer />
            <SearchLayer />
          </template>
        </Stage>
        <Results />
      </div>
    </DocumentGate>
  </Viewer>
</template>
```

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

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

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

<template>
  <ol class="results">
    <li v-for="(hit, index) in hits" :key="`${hit.page.objectNumber}:${hit.start}`">
      <button
        type="button"
        class="result"
        :aria-current="index === activeHitIndex"
        @click="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. -->
        <span v-if="hit.snippet" class="result-text">
          …{{ hit.snippet.before }}<mark>{{ hit.snippet.match }}</mark
          >{{ hit.snippet.after }}…
        </span>
      </button>
    </li>
  </ol>
</template>
```

**`SearchBox.vue`**

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

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

watch(text, (value) => void search.search({ text: value }), { immediate: true });
</script>

<template>
  <div class="toolbar">
    <input v-model="text" class="field" type="search" aria-label="Search" placeholder="Search…" />
    <output class="readout">{{ hitCount }} matches</output>
  </div>
</template>
```

**`results.vue`**

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

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

<template>
  <ol>
    <li v-for="(hit, index) in hits" :key="index">
      <button type="button" @click="search.goToHit(hit)">
        Page {{ hit.pageIndex + 1 }}: …{{ hit.snippet?.before }}
        <mark>{{ hit.snippet?.match }}</mark>
        {{ hit.snippet?.after }}…
      </button>
    </li>
  </ol>
</template>
```

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

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

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

const fromCss = ref(false);
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <div class="toolbar">
        <ColorPicker />
        <label class="option">
          <input v-model="fromCss" type="checkbox" />
          Override with CSS
        </label>
      </div>
      <Stage :class="fromCss ? 'stage from-css' : 'stage'">
        <template #page>
          <RenderLayer />
          <SearchLayer />
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

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

```vue
<script setup lang="ts">
import { useSearch, useSearchSettings } from '@embedpdf/vue/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 current = useSearchSettings((settings) => settings.highlight.color);

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

<template>
  <button
    v-for="{ name, color, activeColor } in COLORS"
    :key="name"
    type="button"
    class="swatch"
    :aria-label="name"
    :aria-pressed="current === color"
    :style="{ background: `linear-gradient(135deg, ${color} 50%, ${activeColor} 50%)` }"
    @click="search.updateSettings({ highlight: { color, activeColor } })"
  />
  <button type="button" class="button" @click="search.resetSettings()">Reset</button>
</template>
```

> **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 @hit-click>` 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.vue`**

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

const search = useSearch();
</script>

<template>
  <SearchLayer @hit-click="(hit) => search.goToHit(hit)" />
</template>
```

**`App.vue`**

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

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>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <ActiveMatch />
      <Pages />
    </DocumentGate>
  </Viewer>
</template>
```

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

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

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

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

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

**`Pages.vue`**

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

const search = useSearch();
</script>

<template>
  <Stage class="stage">
    <template #page>
      <RenderLayer />
      <SearchLayer @hit-click="(hit) => search.goToHit(hit)" />
    </template>
  </Stage>
</template>
```

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 refs: your template updates 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()`.

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

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

const message = ref('');

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

<template>
  <p aria-live="polite">{{ message }}</p>
</template>
```

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