---
title: "How it works — Vue"
description: "How the headless packages fit together: the viewer, plugins, what a plugin gives your components, layers, and which document a component talks to."
framework: "Vue"
source: "https://www.embedpdf.com/docs/headless/vue/concepts/how-it-works"
---

# How it works

A headless viewer is a few parts that always work the same way. Learn them once here, and every
plugin page reads the same: the same four composables, the same kinds of settings and events, the same
checks.

## The viewer and its documents

`<Viewer>` holds the engine, the plugins and the open documents. Everything inside it can use them:

**`viewer.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import { RenderLayer } from '@embedpdf/vue/render';
import { Stage } from '@embedpdf/vue/stage';
import { engine, plugins, source } from './pdf';
import Toolbar from './Toolbar.vue';
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source }]">
    <Toolbar />
    <DocumentGate>
      <template #fallback><p>Opening…</p></template>
      <Stage>
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

A viewer can have several documents open, like tabs, and one of them is active. A component talks
to the active document unless you put it inside `<DocumentScope>`, which is how a tab strip or a
side-by-side view gives each part its own document:

**`document-scope.vue`**

```vue
<script setup lang="ts">
import { DocumentScope } from '@embedpdf/vue/runtime';
import { RenderLayer } from '@embedpdf/vue/render';
import { Stage } from '@embedpdf/vue/stage';

defineProps<{ documentId: string }>();
</script>

<template>
  <DocumentScope :id="documentId">
    <Stage>
      <template #page><RenderLayer /></template>
    </Stage>
  </DocumentScope>
</template>
```

`<DocumentGate>` renders its default slot only once the document is open, so the components inside can
count on it. See [Opening documents](../documents/opening).

## Plugins

A plugin adds one feature. You register it when you create the viewer, with its settings:

**`plugins.ts`**

```typescript
import { renderPlugin } from '@embedpdf/vue/render';
import { searchPlugin } from '@embedpdf/vue/search';
import { stagePlugin } from '@embedpdf/vue/stage';

export const plugins = [stagePlugin({ layout: 'horizontal' }), renderPlugin(), searchPlugin()];
```

Most plugins keep their state per document: each open document has its own search, its own
selection, its own zoom. A few belong to the whole viewer, such as translations, commands and stamp
libraries.

## What a plugin gives you

Every plugin gives your components four composables, each with one job:

| Composable            | Gives you                                      | Your component updates |
| :-------------------- | :--------------------------------------------- | :--------------------- |
| `useSearch()`         | the plugin's API: everything it can do         | never                  |
| `useSearchState()`    | the data to show, such as the number of hits   | when that data changes |
| `useSearchEvent()`    | a call to your function when something happens | never                  |
| `useSearchSettings()` | the plugin's current settings                  | when a setting changes |

Search is the example; every plugin follows the pattern: `useStage()` and `useStageState()`,
`useAnnotation()` and `useAnnotationEvent()`, and so on. A plugin without settings has no settings
composable, and one without events has no event composable.

**`search-box.vue`**

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

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

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

<template>
  <input @input="onInput" />
  <span>{{ activeHitIndex + 1 }} of {{ hitCount }}</span>
  <button @click="search.nextHit()">Next</button>
</template>
```

- **Do things with the API composable.** Its object never changes, so it's safe in watchers and
  event handlers.
- **Show things with the state composable.** It gives you refs: your template updates when a value
  it reads changes.
- **React to things with the event composable.** It listens while the component is mounted, and
  stops when it unmounts:

**`match-announcer.vue`**

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

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

All four together: the API searches, the state shows the count, the event announces each finished
search, and the settings change the highlight color.

**`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 '../hooks.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', bytes: new Uint8Array(await response.arrayBuffer()) };
};
</script>

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

**`hooks.css`**

```css
/* Colors */
: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);
}

/* The search box: what each hook gives it */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

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

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

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

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

/* A highlight color to pick: the setting, changed while the app runs */
.swatch {
  width: 28px;
  height: 28px;
  padding: 0;
  border: 2px solid var(--surface);
  border-radius: 50%;
  box-shadow: 0 0 0 1px var(--line);
  cursor: pointer;
}

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

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

/* What the event hook heard last */
.announcement {
  min-height: 18px;
  margin: 0 0 14px;
  font-size: 12px;
  color: var(--muted);
}

/* The pages */
.stage {
  height: 400px;
  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 { ref, watch } from 'vue';
import { useSearch, useSearchEvent, useSearchSettings, useSearchState } from '@embedpdf/vue/search';

const colors = ['#ffd500', '#7dd3fc', '#86efac'];

// The API: what search can do.
const search = useSearch();
// The data to show: the template updates when the count or the active match changes.
const { hitCount, activeHitIndex } = useSearchState();
// The settings: the template updates when the highlight color changes.
const color = useSearchSettings((settings) => settings.highlight.color);
const text = ref('PDF');
const announcement = ref('');

// A call to your function when something happens.
useSearchEvent(
  (search) => search.onCompleted,
  ({ hitCount }) => (announcement.value = `The search finished with ${hitCount} matches.`),
);

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" />
    <output class="readout">
      {{ hitCount > 0 ? `${activeHitIndex + 1} of ${hitCount}` : 'No matches' }}
    </output>
    <button type="button" class="button" @click="search.nextHit()">Next</button>
    <button
      v-for="swatch in colors"
      :key="swatch"
      type="button"
      class="swatch"
      :aria-label="`Highlight in ${swatch}`"
      :aria-pressed="color === swatch"
      :style="{ background: swatch }"
      @click="search.updateSettings({ highlight: { color: swatch } })"
    />
    <button type="button" class="button" @click="search.resetSettings()">Reset</button>
  </div>
  <p class="announcement" aria-live="polite">{{ announcement }}</p>
</template>
```

A few composables read one specific thing and are named after it, such as
`useAnnotationList(filter)`, `usePageList()` or `useSurface(id)`. Each gives you a ref.

## Layers and overlays

What you draw goes in one of two places.

**Layers** draw inside each page. The Stage renders its `#page` slot once per visible page, and each layer
draws for that page. Later layers sit on top:

**`layers.vue`**

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

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

A layer zooms, scrolls and rotates with its page, and you never convert a position: everything a
plugin gives you is in page coordinates, and the layer places it. A page's layers also keep out of
each other's way: while an annotation layer or a form layer is on the page, the render layer leaves
what it draws out of the page picture, so nothing is drawn twice.

**Overlays** float above the pages, attached to something on them: a menu next to the selected
text, a card next to an annotation. They go in the Stage's `#overlay` slot, which keeps them attached
while the user scrolls and zooms. See [Menus over the page](../ui/anchored).

**`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 { interactionPlugin } from '@embedpdf/vue/interaction';
import { SearchLayer, searchPlugin } from '@embedpdf/vue/search';
import { SelectionLayer, SelectionMenu, selectionPlugin } from '@embedpdf/vue/selection';
import { localEngine } from '@embedpdf/engine';
import Menu from './Menu.vue';
import OnLoad from './OnLoad.vue';

import '../layers.css';

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

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

const showSearch = ref(true);
const showSelection = ref(true);
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Opening…</p></template>
      <OnLoad />
      <div class="toolbar">
        <label class="option">
          <input v-model="showSearch" type="checkbox" />
          Search matches
        </label>
        <label class="option">
          <input v-model="showSelection" type="checkbox" />
          Text selection
        </label>
      </div>
      <!-- Layers draw inside each page, later ones on top; the overlay floats above them. -->
      <Stage class="stage">
        <template #page>
          <RenderLayer />
          <SearchLayer v-if="showSearch" />
          <SelectionLayer v-if="showSelection" />
        </template>
        <template #overlay>
          <SelectionMenu v-if="showSelection">
            <Menu />
          </SelectionMenu>
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`layers.css`**

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

/* Which layers draw on the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

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

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

.option input {
  margin: 0;
  accent-color: var(--accent);
}

/* The overlay: a menu attached to the selected text */
.menu {
  display: flex;
  gap: 4px;
  padding: 4px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--surface);
  box-shadow: 0 8px 24px -8px rgb(7 32 76 / 0.3);
}

.menu button {
  font: inherit;
  font-size: 12px;
  font-weight: 550;
  color: var(--ink);
  background: none;
  border: 0;
  border-radius: 7px;
  padding: 6px 9px;
  cursor: pointer;
}

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

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

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

**`Menu.vue`**

```vue
<script setup lang="ts">
import { useSelection } from '@embedpdf/vue/selection';

const selection = useSelection();
</script>

<template>
  <div class="menu">
    <button type="button" @click="selection.clear()">Clear the selection</button>
  </div>
</template>
```

**`OnLoad.vue`**

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

// Something on every layer on load: the matches of a search, and the title selected.
const search = useSearch();
const selection = useSelection();
const pages = usePageList();
const cover = computed(() => pages.value[0]?.ref);

void search.search({ text: 'PDF' });
watch(
  cover,
  (page) => {
    if (page) selection.select({ page, start: 10, count: 52 });
  },
  { immediate: true },
);
</script>

<template></template>
```

## Settings

A plugin's settings are the options you pass when you register it, and each plugin page lists them
under **Settings**. Every plugin lets you change them while the app runs, for example when the user
picks another layout:

```ts
stage.updateSettings({ layout: 'grid' });
stage.resetSettings(); // back to what you registered
```

Every plugin with settings has the same three calls, a `useStageSettings()`-style
composable, and an `onSettingsChanged` event.

Colors are settings too, such as the highlight of a search match or the handles of an annotation.
Each color setting can also come from a CSS variable, which wins over the setting, for dark mode or a
different look per viewer: see [Colors & theming](../ui/theming).

## Checks before you act

Anything the user may not be allowed to do has a check with the same name:
`annotation.canCreate()`, `search.canSearch()`, `pageEdit.canEdit()`. Use them to hide or disable
buttons. The engine checks every call again, so they keep your UI honest; they're not the guard.
See [Permissions](permissions).

## Good to know

### Code outside components

`@ready` gives you the viewer once it has started, for code that isn't a component: a keyboard
handler, analytics, a test.

**`outside-components.vue`**

```vue
<script setup lang="ts">
import { Viewer, type Kernel } from '@embedpdf/vue/runtime';
import { SearchToken } from '@embedpdf/vue/search';
import { engine, plugins } from './pdf';

function onReady(viewer: Kernel) {
  const search = viewer.capability(SearchToken);
}
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" @ready="onReady" />
</template>
```

Each plugin exports its token (`SearchToken`, `StageToken`, …).

### Plugins don't touch the page

Plugins hold state and logic, never HTML. The framework packages draw. That's why every plugin
works the same in React, Vue, Svelte and Angular, and why you can use a plugin's API in a test
without a browser.

### Coordinates

Every position is in points from the top-left corner of the page, with y going down. Layers and
overlays convert to pixels for you. See [Coordinates](coordinates).
