---
title: "Scroll, zoom & layout — Vue"
description: "Show the pages with the Stage, and control zoom, layouts, page navigation, touch and view rotation."
framework: "Vue"
source: "https://www.embedpdf.com/docs/headless/vue/viewing/stage"
---

# Scroll, zoom & layout

The **Stage** is where the document is shown. It lays out the pages (scrolling, facing pages, a
grid), keeps only the visible ones mounted, and handles scrolling, zoom and page navigation, with
the mouse, the keyboard and on touch screens. You decide what each page draws and what the
controls around it look like.

Register `stagePlugin()` with [`renderPlugin()`](render), put `<Stage>` in your layout, and give
it a scoped slot that draws one page:

**`basic.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 { localEngine } from '@embedpdf/engine';

import './basic.css';

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

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>
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`basic.css`**

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

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

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

> **Try it:** drag to pan, and zoom with Ctrl and the wheel (Cmd on a Mac) or a trackpad pinch. On
> a phone, flick to scroll, pinch to zoom, and double-tap to zoom in.

- **Only visible pages are mounted**, so a 2,000-page document scrolls as smoothly as a short one.
- **Gestures work with no code of your own**: pan, zoom around the pointer, momentum scrolling and
  pinch.
- **Each page is yours.** The scoped slot runs once per visible page. Stack more layers in it (text
  selection, annotations, search highlights) and they move with the page.

## Zoom

`useStageState()` gives you the zoom to show, and `useStage()` the calls to change 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 { localEngine } from '@embedpdf/engine';
import ZoomToolbar from './ZoomToolbar.vue';

import '../zoom.css';

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

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>
      <ZoomToolbar />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`zoom.css`**

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

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

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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;
}

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

/* One choice of a few: the pressed button is the current one */
.segmented {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 3px;
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 11px;
  margin-left: auto;
}

.segmented button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 550;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 6px 12px;
  cursor: pointer;
  white-space: nowrap;
}

.segmented button:hover {
  color: var(--ink);
}

.segmented button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(7 32 76 / 0.1);
}

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

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

**`ZoomToolbar.vue`**

```vue
<script setup lang="ts">
import { useStage, useStageState } from '@embedpdf/vue/stage';

const stage = useStage();
const { zoomLevel, zoomMode } = useStageState();
</script>

<template>
  <div class="toolbar">
    <button type="button" class="button" aria-label="Zoom out" @click="stage.zoomOut()">−</button>
    <output class="readout">{{ Math.round(zoomLevel * 100) }}%</output>
    <button type="button" class="button" aria-label="Zoom in" @click="stage.zoomIn()">+</button>
    <div class="segmented" role="group" aria-label="Fit">
      <button
        type="button"
        :aria-pressed="zoomMode === 'automatic'"
        @click="stage.fitAutomatic()"
      >
        Automatic
      </button>
      <button type="button" :aria-pressed="zoomMode === 'fit-page'" @click="stage.fitPage()">
        Fit page
      </button>
      <button type="button" :aria-pressed="zoomMode === 'fit-width'" @click="stage.fitWidth()">
        Fit width
      </button>
    </div>
  </div>
</template>
```

**`zoom-controls.vue`**

```vue
<script setup lang="ts">
import { useStage, useStageState } from '@embedpdf/vue/stage';

const stage = useStage();
const { zoomLevel, zoomMode } = useStageState();
</script>

<template>
  <div>
    <button @click="stage.zoomOut()">−</button>
    <span>{{ Math.round(zoomLevel * 100) }}%</span>
    <button @click="stage.zoomIn()">+</button>
    <button :aria-pressed="zoomMode === 'fit-width'" @click="stage.fitWidth()">Fit width</button>
  </div>
</template>
```

`zoomLevel` is a plain number: `1` is 100%, `2` is 200%. And 100% means the page's real size: a Letter
page shows as big as the paper, as it does in Acrobat.

A fit mode keeps applying when the window resizes. As soon as the user pinches or presses **+**, the
mode becomes `'custom'` and their zoom stays.

| Mode          | What it does                                                 |
| :------------ | :----------------------------------------------------------- |
| `'automatic'` | Fits the page width, but never zooms past 100%. The default. |
| `'fit-page'`  | Fits the whole page in view.                                 |
| `'fit-width'` | Fills the full width.                                        |
| `'fit-all'`   | Zooms out until every page is visible.                       |

For an exact level, a mode or a fixed page size, use `zoomTo`:

```ts
stage.zoomTo(1.5); // exactly 150%
stage.zoomTo({ mode: 'fit-width' });
stage.zoomTo({ pageWidth: 200 }); // every page 200 pixels wide, for thumbnails
stage.zoomBy(1.25, { around: { x: 400, y: 300 } }); // 25% closer, around a point in the Stage
```

## Move through pages

**`page-navigation.vue`**

```vue
<script setup lang="ts">
import { useStage, useStageState } from '@embedpdf/vue/stage';

const stage = useStage();
const { currentPageIndex, pageCount } = useStageState();
</script>

<template>
  <nav>
    <button @click="stage.previousPage()">Previous</button>
    <span>{{ currentPageIndex + 1 }} of {{ pageCount }}</span>
    <button @click="stage.nextPage()">Next</button>
  </nav>
</template>
```

```ts
stage.goToPage(4); // the fifth page
stage.goToPage(page.ref); // or by its ref
stage.goToFirstPage();
stage.goToLastPage();
```

**`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 { localEngine } from '@embedpdf/engine';
import PageToolbar from './PageToolbar.vue';

import '../navigation.css';

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

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>
      <PageToolbar />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`navigation.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 page controls, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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;
}

.badge {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--muted);
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 6px 11px;
  white-space: nowrap;
}

.badge strong {
  color: var(--ink);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.field {
  width: 118px;
  margin-left: auto;
  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);
}

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

**`PageToolbar.vue`**

```vue
<script setup lang="ts">
import { ref } from 'vue';
import { useStage, useStageState } from '@embedpdf/vue/stage';

const stage = useStage();
const { currentPageIndex, pageCount } = useStageState();
const typed = ref('');

// People count from 1, an index from 0. An index past the end goes to the last page.
function jump() {
  const number = Number(typed.value);
  if (Number.isInteger(number) && number >= 1) stage.goToPage(number - 1);
  typed.value = '';
}
</script>

<template>
  <div class="toolbar">
    <button
      type="button"
      class="button"
      :disabled="!stage.canGoPrevious()"
      @click="stage.previousPage()"
    >
      ‹ Previous
    </button>
    <output class="badge">
      Page <strong>{{ currentPageIndex + 1 }} / {{ pageCount }}</strong>
    </output>
    <button type="button" class="button" :disabled="!stage.canGoNext()" @click="stage.nextPage()">
      Next ›
    </button>
    <input
      v-model="typed"
      class="field"
      inputmode="numeric"
      aria-label="Go to page"
      placeholder="Go to page…"
      @keydown.enter="jump"
    />
  </div>
</template>
```

> **Try it:** zoom in, then press **Next**. The next page lands with its top edge at the top of the
> view, at every zoom.

- **A page is its `ref` or its index.** Every call that takes a page takes either. An index counts
  from 0, so page "1" in your UI is `goToPage(0)`, and an index past the end goes to the last page.
- **`nextPage()` and `previousPage()` follow spreads.** When two facing pages fit, they move a whole
  spread; zoomed in, one page.
- **`reveal()` scrolls only as far as needed.** It makes a page visible and does nothing if it
  already is. Use it for a thumbnail strip that follows along without jumping.
- **You can navigate as soon as the document opens.** A call made before the Stage is on screen
  waits for it, and the last one is where the Stage opens, without scrolling there.

## Layouts

The arrangement of the pages is four settings. Set them when you register the plugin, or change
them while the app runs. The Stage keeps you on the same page while it reflows:

**`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 { localEngine } from '@embedpdf/engine';
import LayoutControls from './LayoutControls.vue';

import '../layouts.css';

const engine = localEngine();
// Facing pages from the start, like a book.
const plugins = [stagePlugin({ spread: 'odd' }), renderPlugin()];

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>
      <LayoutControls />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`layouts.css`**

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

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

/* A small caption in front of a control */
.label {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--label);
}

.select {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  letter-spacing: 0;
  text-transform: none;
  color: var(--ink);
  background-color: var(--surface);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235a6b92' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 10px center;
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 30px 8px 12px;
  cursor: pointer;
  appearance: none;
  box-shadow: 0 1px 2px var(--shadow);
}

.select:hover {
  border-color: var(--line-strong);
}

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

.pager {
  display: flex;
  gap: 8px;
  margin-left: auto;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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);
}
```

**`LayoutControls.vue`**

```vue
<script setup lang="ts">
import { useStage, useStageSettings } from '@embedpdf/vue/stage';
import type { FlowMode, LayoutKind, SpreadMode } from '@embedpdf/vue/stage';

const stage = useStage();
const { flow, layout, spread } = useStageSettings();

const valueOf = (event: Event) => (event.target as HTMLSelectElement).value;
</script>

<template>
  <div class="toolbar">
    <label class="label">
      Flow
      <select
        class="select"
        :value="flow"
        @change="stage.updateSettings({ flow: valueOf($event) as FlowMode })"
      >
        <option value="continuous">continuous</option>
        <option value="paged">paged</option>
      </select>
    </label>
    <label class="label">
      Layout
      <select
        class="select"
        :value="layout"
        @change="stage.updateSettings({ layout: valueOf($event) as LayoutKind })"
      >
        <option value="vertical">vertical</option>
        <option value="horizontal">horizontal</option>
        <option value="grid">grid</option>
      </select>
    </label>
    <label class="label">
      Spread
      <select
        class="select"
        :value="spread"
        @change="stage.updateSettings({ spread: valueOf($event) as SpreadMode })"
      >
        <option value="none">none</option>
        <option value="odd">odd</option>
        <option value="even">even</option>
      </select>
    </label>
    <div class="pager">
      <button type="button" class="button" aria-label="Previous" @click="stage.previousPage()">
        ‹
      </button>
      <button type="button" class="button" aria-label="Next" @click="stage.nextPage()">›</button>
    </div>
  </div>
</template>
```

```ts
stage.updateSettings({ layout: 'horizontal', spread: 'odd' });
```

| Setting  | What it decides                                        | Values                                           |
| :------- | :----------------------------------------------------- | :----------------------------------------------- |
| `flow`   | Scroll every page, or one page or spread at a time     | `'continuous'` (default), `'paged'`              |
| `layout` | How pages are arranged                                 | `'vertical'` (default), `'horizontal'`, `'grid'` |
| `spread` | Facing pages side by side, like a book                 | `'none'` (default), `'odd'`, `'even'`            |
| `sizing` | Each page at its true size, or every page equally wide | `'intrinsic'` (default), `'uniform'`             |

Two combinations you'll reach for often:

```ts
// A book: facing pages, one spread at a time
stagePlugin({ flow: 'paged', spread: 'odd' });

// A thumbnail grid that wraps to the available width
stagePlugin({ layout: 'grid', columns: 'auto', zoom: { pageWidth: 150 } });
```

`columns` applies to the grid: `'auto'` wraps to fit, `'square'` aims for a square grid, and a
number fixes the count.

## Space around pages

- **`padding`** is the space between the pages and the edge of the Stage, in screen pixels. Fit
  modes respect it, so "fit page" never touches the edges. It's `24`, or `4` on narrow screens.
- **`gap`** is the space between pages. A number (`16`, the default) grows with the zoom, like the
  space between sheets of paper. `{ px: 16 }` stays 16 screen pixels at every zoom, which suits a
  thumbnail strip.

```ts
stagePlugin({ padding: 32, gap: { px: 12 } });
```

**`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 { localEngine } from '@embedpdf/engine';
import SpacingControls from './SpacingControls.vue';

import '../spacing.css';

const engine = localEngine();
// No responsive rules, so the padding you set applies at every width.
const plugins = [stagePlugin({ padding: 32, gap: { px: 12 }, responsive: [] }), renderPlugin()];

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>
      <SpacingControls />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`spacing.css`**

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

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

/* A small caption in front of a control */
.label {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--label);
}

.range {
  width: 110px;
  accent-color: var(--accent);
}

.value {
  min-width: 22px;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.zoom {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-left: auto;
}

/* One choice of a few: the pressed button is the current one */
.segmented {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 3px;
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 11px;
}

.segmented button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 550;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 6px 12px;
  cursor: pointer;
  white-space: nowrap;
}

.segmented button:hover {
  color: var(--ink);
}

.segmented button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(7 32 76 / 0.1);
}

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

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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;
}

.readout {
  min-width: 52px;
  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);
}
```

**`SpacingControls.vue`**

```vue
<script setup lang="ts">
import { computed } from 'vue';
import { useStage, useStageSettings, useStageState } from '@embedpdf/vue/stage';

const stage = useStage();
const { padding, gap } = useStageSettings();
const zoomLevel = useStageState((state) => state.zoomLevel);
// A number grows with the zoom; { px } stays the same on screen.
const onScreen = computed(() => typeof gap.value !== 'number');
const gapSize = computed(() => (typeof gap.value === 'number' ? gap.value : gap.value.px));

const numberOf = (event: Event) => Number((event.target as HTMLInputElement).value);

function setGap(event: Event) {
  const size = numberOf(event);
  stage.updateSettings({ gap: onScreen.value ? { px: size } : size });
}
</script>

<template>
  <div class="toolbar">
    <label class="label">
      Padding
      <input
        class="range"
        type="range"
        :min="0"
        :max="64"
        :value="padding"
        @input="stage.updateSettings({ padding: numberOf($event) })"
      />
      <output class="value">{{ padding }}</output>
    </label>
    <label class="label">
      Gap
      <input class="range" type="range" :min="0" :max="64" :value="gapSize" @input="setGap" />
      <output class="value">{{ gapSize }}</output>
    </label>
    <div class="segmented" role="group" aria-label="Gap unit">
      <button
        type="button"
        :aria-pressed="onScreen"
        @click="stage.updateSettings({ gap: { px: gapSize } })"
      >
        Screen pixels
      </button>
      <button
        type="button"
        :aria-pressed="!onScreen"
        @click="stage.updateSettings({ gap: gapSize })"
      >
        Grows with zoom
      </button>
    </div>
    <div class="zoom">
      <button type="button" class="button" aria-label="Zoom out" @click="stage.zoomOut()">−</button>
      <output class="readout">{{ Math.round(zoomLevel * 100) }}%</output>
      <button type="button" class="button" aria-label="Zoom in" @click="stage.zoomIn()">+</button>
    </div>
  </div>
</template>
```

## Adapt to the space you have

A generous margin on a desktop wastes half a phone screen. Settings can depend on how much room
the Stage has, like a CSS container query:

```ts
stagePlugin({
  padding: 24,
  spread: 'odd',
  responsive: [
    { name: 'compact', when: { maxWidth: 600 }, settings: { padding: 4 } },
    { when: { orientation: 'portrait' }, settings: { spread: 'none' } },
  ],
});
```

Every rule whose `when` matches applies, in order, and a later rule wins on the settings they share.
`when` takes `minWidth`, `maxWidth`, `minHeight`, `maxHeight` and `orientation`, all combined with
*and*, or a function for anything else: `(box) => box.width / box.height > 2`.

- **It measures the Stage, not the device.** A 500-pixel pane on a desktop is compact too, and two
  Stages in one window decide on their own.
- **A rule applies when the size crosses it.** In between, the user is in charge: pinch to 300%,
  resize the window, and the zoom stays while the padding rule still applies.

**`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 { localEngine } from '@embedpdf/engine';
import Toolbar from './Toolbar.vue';

import '../responsive.css';

const engine = localEngine();

// Facing pages with a roomy margin, and one rule for a narrow Stage: a thin
// margin and one page at a time. The rule has a name, so the UI can read it too.
const plugins = [
  stagePlugin({
    padding: 24,
    spread: 'odd',
    responsive: [
      { name: 'compact', when: { maxWidth: 600 }, settings: { padding: 4, spread: 'none' } },
    ],
  }),
  renderPlugin(),
];

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 narrow = ref(false);
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Toolbar v-model:narrow="narrow" />
      <div class="frame" :data-width="narrow ? 'narrow' : 'full'">
        <Stage class="stage">
          <template #page><RenderLayer /></template>
        </Stage>
      </div>
    </DocumentGate>
  </Viewer>
</template>
```

**`responsive.css`**

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

/* The width switch, what the rule decided, and the page buttons */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

/* One choice of a few: the pressed button is the current one */
.segmented {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 3px;
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 11px;
}

.segmented button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 550;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 6px 12px;
  cursor: pointer;
  white-space: nowrap;
}

.segmented button:hover {
  color: var(--ink);
}

.segmented button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(7 32 76 / 0.1);
}

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

.badge {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--muted);
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 6px 11px;
  white-space: nowrap;
}

.badge strong {
  color: var(--ink);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* The badge lights up while the compact rule applies */
.badge[data-on='true'] {
  color: var(--accent);
  background: var(--accent-soft);
  border-color: var(--line-strong);
}

.pager {
  display: flex;
  gap: 8px;
  margin-left: auto;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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 Stage measures its own box: a narrow frame is compact on any screen */
.frame {
  margin: 0 auto;
  transition: max-width 0.25s ease;
}

.frame[data-width='narrow'] {
  max-width: 360px;
}

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

**`Toolbar.vue`**

```vue
<script setup lang="ts">
import { useStage, useStageSettings, useStageState } from '@embedpdf/vue/stage';

const narrow = defineModel<boolean>('narrow', { required: true });

const stage = useStage();
// One breakpoint drives both the layout and this toolbar.
const compact = useStageState((state) => state.activeRules.includes('compact'));
const { padding, spread } = useStageSettings();
</script>

<template>
  <div class="toolbar">
    <div class="segmented" role="group" aria-label="Stage width">
      <button type="button" :aria-pressed="!narrow" @click="narrow = false">Full width</button>
      <button type="button" :aria-pressed="narrow" @click="narrow = true">360 px</button>
    </div>
    <output class="badge" :data-on="compact">
      compact <strong>{{ compact ? 'on' : 'off' }}</strong>
    </output>
    <output class="badge">
      padding <strong>{{ padding }}</strong> · spread <strong>{{ spread }}</strong>
    </output>
    <div class="pager">
      <button type="button" class="button" @click="stage.previousPage()">
        {{ compact ? '‹' : '‹ Previous' }}
      </button>
      <button type="button" class="button" @click="stage.nextPage()">
        {{ compact ? '›' : 'Next ›' }}
      </button>
    </div>
  </div>
</template>
```

> **Try it:** switch the Stage to 360 pixels. The rule turns on, the facing pages become one, and
> the toolbar's buttons get shorter, all from the same breakpoint.

Out of the box there's one rule, the `compact` margin above. Pass your own list to replace it, or
`responsive: []` for none. `stage.setResponsiveRules([…])` changes the rules while the app runs.

A named rule is also something your UI can read, so one breakpoint drives both the layout and your
own chrome:

**`compact-layout.vue`**

```vue
<script setup lang="ts">
import { computed } from 'vue';
import { useStageState } from '@embedpdf/vue/stage';
import BottomSheet from './BottomSheet.vue';
import SidePanel from './SidePanel.vue';

const { activeRules } = useStageState();
const compact = computed(() => activeRules.value.includes('compact'));
</script>

<template>
  <BottomSheet v-if="compact" />
  <SidePanel v-else />
</template>
```

## On touch screens

Touch works out of the box, tuned to feel like the platform rather than a web page:

- **One finger scrolls**, with momentum: flick and the page glides to a stop.
- **Two fingers pinch-zoom** around the point between them, and pan at the same time.
- **Double-tap zooms in steps**: the whole page, the text at full width, closer, and back out.
- **Edges are elastic.** Pulling past the end stretches and springs back.
- **Long-press selects a word** when the [Selection](../text/selection) plugin is registered.

For a Stage that should scroll but not zoom under a pinch or Ctrl+wheel, such as a thumbnail strip,
set its `zoomGestures` setting to `false`.

## Labels and buttons on every page

For UI that belongs to a page but shouldn't sit on it, like a page number below or a row of
buttons above, reserve space with `pageFrame` and draw into it with
the `#page-chrome` slot:

**`page-labels.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 { localEngine } from '@embedpdf/engine';

import './page-labels.css';

const engine = localEngine();

// Reserve a 26px band below every page: the label lives there, so it never
// covers the page and keeps its size when you zoom.
const plugins = [stagePlugin({ pageFrame: { bottom: 26 } }), renderPlugin()];

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>
      <Stage class="stage">
        <template #page><RenderLayer /></template>
        <template #page-chrome="{ page }">
          <div class="page-label" :style="{ height: `${page.frame.bottom}px` }">
            Page {{ page.pageIndex + 1 }}
          </div>
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`page-labels.css`**

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

/* The label, in the band reserved below each page */
.page-label {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  font-weight: 600;
  color: var(--label);
  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);
}
```

The reserved bands are in screen pixels, so labels keep their size at every zoom. They count as part
of the page: "fit page" includes them, and going to a page shows its label.

Each page has a drop shadow so it reads as paper. It's the viewer's `page.shadow` setting (`'none'`
for no shadow), next to `page.background`, the color of a page before its picture arrives. Both can
also come from CSS, which wins over the setting:

```css
.pdf-viewer {
  --epdf-page-shadow: 0 2px 8px rgb(0 0 0 / 0.25); /* or none */
}
```

See [Colors & theming](../ui/theming).

## Scrollbars

`<Scrollbar>` is a scrollbar for the Stage that you style yourself: drag the thumb, click the track
to page, and it hides when the pointer leaves, like a macOS scrollbar:

**`App.vue`**

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

import '../scrollbar.css';

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

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>
      <ReadingProgress />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
        <template #overlay>
          <Scrollbar axis="y" :auto-hide="1200" class="scrollbar" thumb-class="thumb" />
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`scrollbar.css`**

```css
/* Colors */
:root {
  --muted: #5a6b92;
  --line: #e6eaf2;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The reading progress, above the pages */
.progress {
  height: 5px;
  margin-bottom: 12px;
  background: var(--tint);
  border-radius: 999px;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  border-radius: 999px;
  background: linear-gradient(90deg, #0876fd, #7db6ff);
  transition: width 0.12s linear;
}

/* The scrollbar: the component places it, these rules give it its look */
.scrollbar {
  margin: 6px 2px;
  border-radius: 999px;
  transition:
    opacity 0.2s,
    background-color 0.15s ease;
}

.scrollbar:hover,
.scrollbar[data-dragging] {
  background: rgb(7 32 76 / 0.06);
}

.thumb {
  --epdf-scrollbar-thumb: #9aaccc;
  cursor: grab;
}

.scrollbar:hover .thumb,
.scrollbar[data-dragging] .thumb {
  --epdf-scrollbar-thumb: var(--accent);
}

.scrollbar[data-dragging] .thumb {
  cursor: grabbing;
}

/* The pages */
.stage {
  height: 440px;
  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);
}
```

**`ReadingProgress.vue`**

```vue
<!-- A reading-progress bar, from the same numbers a scrollbar uses. -->
<script setup lang="ts">
import { computed } from 'vue';
import { useScrollMetrics } from '@embedpdf/vue/stage';

const { scrollTop, scrollHeight, clientHeight } = useScrollMetrics();
const progress = computed(() => {
  const travel = scrollHeight.value - clientHeight.value;
  return travel > 0 ? Math.round((scrollTop.value / travel) * 100) : 0;
});
</script>

<template>
  <div class="progress" role="progressbar" aria-label="Reading progress" :aria-valuenow="progress">
    <div class="progress-fill" :style="{ width: `${progress}%` }" />
  </div>
</template>
```

**`scrollbar.vue`**

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

<template>
  <Scrollbar axis="y" :auto-hide="1200" class="my-scrollbar" thumb-class="my-thumb" />
</template>
```

| Prop             | What it is                                                 | Default  |
| :--------------- | :--------------------------------------------------------- | :------- |
| `axis`           | `'x'` or `'y'`                                             | required |
| `auto-hide`      | Milliseconds before it hides, or `false` to always show it | `1200`   |
| `min-thumb-size` | The smallest thumb, in pixels, for very long documents     | `24`     |
| `track-press`    | A click on the track moves a `'page'` or `'jump'`s there   | `'page'` |

It sets `data-axis`, `data-state` and `data-dragging` for your CSS; until you style the thumb, its
color is `--epdf-scrollbar-thumb`. To build your own, such as a
reading-progress bar, `useScrollMetrics()` gives you `scrollTop`, `scrollHeight` and
`clientHeight`, the same numbers a scrolling element has.

## Jump to an exact spot

Give `reveal()` a rectangle and it becomes your "go to this search result" call:

```ts
stage.reveal(hit.page, {
  rect: hit.bounds, // a box in page coordinates
  zoom: 'fit-width', // zoom so the box spans the view, or 'keep'
  anchor: { y: 0.35 }, // about a third from the top, like a browser's find bar
});
```

Search results, comments and bookmarks all come down to this one call.

**`App.vue`**

```vue
<script setup lang="ts">
import { ref } from 'vue';
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput, PageContextValue } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import type { RevealZoom } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { localEngine } from '@embedpdf/engine';
import SpotPicker from './SpotPicker.vue';
import { SPOTS } from './spots';

import '../reveal.css';

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

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 active = ref(0);
const zoom = ref<RevealZoom>('fit-width');

// Page coordinates become pixels only here, as the spot is drawn.
function boxOn(page: PageContextValue, rect: (typeof SPOTS)[number]['rect']) {
  const box = page.transform.pageToViewRect(rect);
  return { left: `${box.x}px`, top: `${box.y}px`, width: `${box.width}px`, height: `${box.height}px` };
}
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <SpotPicker v-model:active="active" v-model:zoom="zoom" />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
        <template #page-chrome="{ page }">
          <template v-for="(spot, index) in SPOTS" :key="spot.label">
            <div
              v-if="spot.page === page.pageIndex"
              class="spot"
              :data-active="index === active"
              :style="boxOn(page, spot.rect)"
            />
          </template>
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`reveal.css`**

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

/* The spots, and how the view zooms to them */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.spots {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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;
}

.button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--accent-soft);
  border-color: var(--line-strong);
}

/* One choice of a few: the pressed button is the current one */
.segmented {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 3px;
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 11px;
  margin-left: auto;
}

.segmented button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 550;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 6px 12px;
  cursor: pointer;
  white-space: nowrap;
}

.segmented button:hover {
  color: var(--ink);
}

.segmented button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(7 32 76 / 0.1);
}

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

/* Each spot, drawn on its page; the one you went to stands out */
.spot {
  position: absolute;
  box-sizing: border-box;
  border: 2px dashed rgb(8 118 253 / 0.45);
  border-radius: 6px;
  pointer-events: none;
  transition:
    border-color 0.2s ease,
    background-color 0.2s ease;
}

.spot[data-active='true'] {
  border-style: solid;
  border-color: var(--mark);
  background: rgb(8 118 253 / 0.08);
}

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

**`SpotPicker.vue`**

```vue
<script setup lang="ts">
import { onMounted } from 'vue';
import { useStage, useStageState } from '@embedpdf/vue/stage';
import type { RevealZoom } from '@embedpdf/vue/stage';
import { SPOTS, ZOOMS } from './spots';

const active = defineModel<number>('active', { required: true });
const zoom = defineModel<RevealZoom>('zoom', { required: true });

const stage = useStage();
const pageCount = useStageState((state) => state.pageCount);

function reveal(index: number, revealZoom: RevealZoom) {
  const spot = SPOTS[index];
  // The box lands about a third from the top, like a browser's find bar.
  stage.reveal(spot.page, { rect: spot.rect, zoom: revealZoom, anchor: { y: 0.35 } });
  active.value = index;
}

// Open on the first spot.
onMounted(() => {
  stage.reveal(SPOTS[0].page, { rect: SPOTS[0].rect, zoom: 'fit-width', anchor: { y: 0.35 } });
});
</script>

<template>
  <div class="toolbar">
    <div class="spots">
      <button
        v-for="(spot, index) in SPOTS"
        :key="spot.label"
        type="button"
        class="button"
        :aria-pressed="index === active"
        :disabled="spot.page >= pageCount"
        @click="reveal(index, zoom)"
      >
        {{ spot.label }}
      </button>
    </div>
    <div class="segmented" role="group" aria-label="Zoom">
      <button
        v-for="option in ZOOMS"
        :key="option.label"
        type="button"
        :aria-pressed="option.zoom === zoom"
        @click="zoom = option.zoom"
      >
        {{ option.label }}
      </button>
    </div>
  </div>
</template>
```

**`spots.ts`**

```typescript
import type { RevealZoom } from '@embedpdf/vue/stage';

// Spots to jump to: a page index and a box in page coordinates (points from
// the page's top-left), like a search hit or a comment would give you.
export const SPOTS = [
  { label: 'Top of page 2', page: 1, rect: { x: 72, y: 72, width: 468, height: 96 } },
  { label: 'Middle of page 3', page: 2, rect: { x: 72, y: 340, width: 468, height: 120 } },
  { label: 'Corner of page 4', page: 3, rect: { x: 330, y: 620, width: 210, height: 110 } },
];

export const ZOOMS: { label: string; zoom: RevealZoom }[] = [
  { label: 'Keep zoom', zoom: 'keep' },
  { label: 'Fit width', zoom: 'fit-width' },
  { label: 'Fit the box', zoom: 'fit' },
];
```

## Follow a destination

A destination is where a link, a bookmark or the document's opening view points: a page, a spot on
it, and a zoom. `goToDestination()` goes exactly there:

```ts
stage.goToDestination(destination);

// Page 5, with the point (72, 100) at the top-left of the view, at 200%
stage.goToDestination({ kind: 'xyz', page: pages[4].ref, x: 72, y: 100, zoom: 2 });
```

| `kind`                         | Goes to                                               |
| :----------------------------- | :---------------------------------------------------- |
| `'xyz'`                        | The point `x`, `y` at the top-left, at `zoom`         |
| `'fit'`                        | The whole page                                        |
| `'fitH'`                       | The page's full width, with `y` at the top            |
| `'fitV'`                       | The page's full height, with `x` at the left          |
| `'fitR'`                       | The box `x`, `y`, `width`, `height`, filling the view |
| `'fitB'`, `'fitBH'`, `'fitBV'` | The same as `fit`, `fitH` and `fitV`                  |

Any of `x`, `y` and `zoom` can be `null` or left out: the view keeps what it has on that axis.

**`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 { localEngine } from '@embedpdf/engine';
import Destinations from './Destinations.vue';

import '../destination.css';

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

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>
      <Destinations />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

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

/* One button per kind of destination, and where the view went */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

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

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

.badge {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--muted);
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 6px 11px;
  white-space: nowrap;
  margin-left: auto;
}

.badge strong {
  color: var(--ink);
  font-weight: 700;
  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);
}
```

**`Destinations.vue`**

```vue
<script setup lang="ts">
import { computed, watch } from 'vue';
import { usePageList } from '@embedpdf/vue/runtime';
import type { PageDestination, PageInfo } from '@embedpdf/vue/runtime';
import { useStage, useStageState } from '@embedpdf/vue/stage';

const stage = useStage();
const pages = usePageList();
const { zoomLevel, currentPageIndex } = useStageState();

// Destinations as a link, a bookmark or the document's opening view give them.
const destinationsFor = (list: readonly PageInfo[]) => {
  const pageAt = (index: number) => (list[index] ?? list[list.length - 1]).ref;
  return [
    {
      label: 'xyz: page 3 at (72, 100), 200%',
      destination: { kind: 'xyz', page: pageAt(2), x: 72, y: 100, zoom: 2 },
    },
    { label: 'fit: all of page 2', destination: { kind: 'fit', page: pageAt(1) } },
    { label: 'fitH: page 1 from y = 300', destination: { kind: 'fitH', page: pageAt(0), y: 300 } },
    {
      label: 'fitR: a box on page 4',
      destination: { kind: 'fitR', page: pageAt(3), x: 72, y: 420, width: 260, height: 160 },
    },
  ] satisfies { label: string; destination: PageDestination }[];
};
const destinations = computed(() => destinationsFor(pages.value));

// Open where the first destination points.
watch(
  pages,
  (list) => {
    if (list.length > 0) stage.goToDestination(destinationsFor(list)[0].destination);
  },
  { immediate: true },
);
</script>

<template>
  <div class="toolbar">
    <button
      v-for="{ label, destination } in destinations"
      :key="label"
      type="button"
      class="button"
      @click="stage.goToDestination(destination)"
    >
      {{ label }}
    </button>
    <output class="badge">
      page <strong>{{ currentPageIndex + 1 }}</strong> ·
      <strong>{{ Math.round(zoomLevel * 100) }}%</strong>
    </output>
  </div>
</template>
```

## Make it feel right

When someone clicks "next page", should the page land at the top of the view, or centered like a
slide? Four settings answer questions like that. They all take `'start'`, `'center'`, `'end'` or a
fraction such as `0.35`, per axis:

| Setting        | The question it answers                                       | Default    |
| :------------- | :------------------------------------------------------------ | :--------- |
| `arrivalAlign` | Where does a page land when you go to it?                     | the top    |
| `zoomAlign`    | What stays put when you zoom with a button?                   | the center |
| `anchorAlign`  | What stays put when the window resizes or the layout changes? | the top    |
| `fitAlign`     | Where does the content rest when everything fits?             | the center |

The defaults feel like reading a document. Set them all to center for a presentation, where each
move keeps the page centered like a slide:

**`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 { localEngine } from '@embedpdf/engine';
import FeelSwitcher from './FeelSwitcher.vue';

import '../presets.css';

const engine = localEngine();
// Zoomed in, so a page is wider than the view and you can see where it lands.
const plugins = [stagePlugin({ zoom: { level: 1.6 } }), renderPlugin()];

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>
      <FeelSwitcher />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`presets.css`**

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

/* The feel switch and the page buttons, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

/* One choice of a few: the pressed button is the current one */
.segmented {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 3px;
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 11px;
}

.segmented button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 550;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 6px 12px;
  cursor: pointer;
  white-space: nowrap;
}

.segmented button:hover {
  color: var(--ink);
}

.segmented button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(7 32 76 / 0.1);
}

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

.pager {
  display: flex;
  gap: 8px;
  margin-left: auto;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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);
}
```

**`FeelSwitcher.vue`**

```vue
<script setup lang="ts">
import { useStage, useStageSettings } from '@embedpdf/vue/stage';
import type { StageSettings } from '@embedpdf/vue/stage';

// A preset is an object you keep, and apply with updateSettings().
const READING: Partial<StageSettings> = {
  arrivalAlign: { x: 'start', y: 'start' },
  zoomAlign: { x: 'center', y: 'center' },
  anchorAlign: { x: 'start', y: 'start' },
};
const PRESENTATION: Partial<StageSettings> = {
  arrivalAlign: { x: 'center', y: 'center' },
  zoomAlign: { x: 'center', y: 'center' },
  anchorAlign: { x: 'center', y: 'center' },
};

const stage = useStage();
// The settings say which feel is on: pages land centered in a presentation.
const presentation = useStageSettings((settings) => settings.arrivalAlign.y === 'center');
</script>

<template>
  <div class="toolbar">
    <div class="segmented" role="group" aria-label="Feel">
      <button type="button" :aria-pressed="!presentation" @click="stage.updateSettings(READING)">
        Reading
      </button>
      <button
        type="button"
        :aria-pressed="presentation"
        @click="stage.updateSettings(PRESENTATION)"
      >
        Presentation
      </button>
    </div>
    <div class="pager">
      <button type="button" class="button" @click="stage.previousPage()">‹ Previous</button>
      <button type="button" class="button" @click="stage.nextPage()">Next ›</button>
    </div>
  </div>
</template>
```

```ts
const presentation = {
  arrivalAlign: { x: 'center', y: 'center' },
  zoomAlign: { x: 'center', y: 'center' },
  anchorAlign: { x: 'center', y: 'center' },
};

stage.updateSettings(presentation); // one change, and you keep your place
```

One call can override the setting just for itself: `stage.goToPage(12, { arrivalAlign: { y:
'center' } })`. `'keep'` means "don't move this axis": with `arrivalAlign: { x: 'keep', y: 'start'
}`, someone reading the left column of a two-column paper stays in that column as they page on.

## Rotate the view

For a scan that came out sideways, turn how the pages are shown without changing the file:

**`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 { localEngine } from '@embedpdf/engine';
import RotateButtons from './RotateButtons.vue';

import '../rotate-view.css';

const engine = localEngine();
// The pages start a quarter turn round, like a scan that came out sideways.
const plugins = [stagePlugin({ viewRotation: 90 }), renderPlugin()];

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>
      <RotateButtons />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`rotate-view.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 rotate buttons, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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;
}

.readout {
  min-width: 52px;
  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);
}
```

**`RotateButtons.vue`**

```vue
<script setup lang="ts">
import { useStage, useStageState } from '@embedpdf/vue/stage';

const stage = useStage();
const viewRotation = useStageState((state) => state.viewRotation);
</script>

<template>
  <div class="toolbar">
    <button type="button" class="button" @click="stage.rotateViewBy(-90)">⟲ Rotate left</button>
    <output class="readout">{{ viewRotation }}°</output>
    <button type="button" class="button" @click="stage.rotateViewBy(90)">⟳ Rotate right</button>
    <button
      type="button"
      class="button"
      :disabled="viewRotation === 0"
      @click="stage.setViewRotation(0)"
    >
      Upright
    </button>
  </div>
</template>
```

```ts
stage.rotateViewBy(90); // a quarter turn clockwise from here
stage.setViewRotation(180); // or an exact rotation
```

To rotate pages in the PDF itself, use [Organizing pages](../documents/pages). That's an edit to the
document, not a view setting, so keep the two on different buttons.

## Remember where the user was

```ts
// One page: remember the spot before leaving, go back to it later
const spot = stage.getViewpoint();
stage.goToPage(2, { viewpoint: spot });

// Everything: settings and position, as one object you can store
localStorage.setItem('view', JSON.stringify(stage.getViewState()));
stage.applyViewState(JSON.parse(localStorage.getItem('view')!));
```

A viewpoint remembers *what* you were looking at, not pixel offsets, so it restores correctly in a
window of another size.

**`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 { localEngine } from '@embedpdf/engine';
import Memory from './Memory.vue';

import '../viewpoint.css';

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

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>
      <Memory />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

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

/* Remembering a spot, and the whole view */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.group {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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;
}

.badge {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--muted);
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 6px 11px;
  white-space: nowrap;
  margin-left: auto;
}

.badge strong {
  color: var(--ink);
  font-weight: 700;
  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);
}
```

**`Memory.vue`**

```vue
<script setup lang="ts">
import { onMounted, shallowRef } from 'vue';
import type { PageRef } from '@embedpdf/vue/runtime';
import { useStage, useStageState } from '@embedpdf/vue/stage';
import type { StageViewState, Viewpoint } from '@embedpdf/vue/stage';

const stage = useStage();
const { currentPageIndex, zoomLevel } = useStageState();
// One spot on one page, and the whole view: settings and position.
const spot = shallowRef<{ page: PageRef; viewpoint: Viewpoint; label: string } | null>(null);
const view = shallowRef<StageViewState | null>(null);

function rememberSpot() {
  const page = stage.getCurrentPage();
  if (!page) return;
  const label = `page ${stage.getCurrentPageIndex() + 1}`;
  spot.value = { page, viewpoint: stage.getViewpoint(), label };
}

function goBack() {
  if (spot.value) stage.goToPage(spot.value.page, { viewpoint: spot.value.viewpoint });
}

function restore() {
  if (view.value) stage.applyViewState(view.value);
}

// Remember where the reader starts, so "Go back" works right away.
onMounted(rememberSpot);
</script>

<template>
  <div class="toolbar">
    <div class="group">
      <button type="button" class="button" @click="rememberSpot">Remember this spot</button>
      <button type="button" class="button" :disabled="!spot" @click="goBack">
        Go back{{ spot ? ` to ${spot.label}` : '' }}
      </button>
    </div>
    <div class="group">
      <button type="button" class="button" @click="view = stage.getViewState()">
        Save the view
      </button>
      <button type="button" class="button" :disabled="!view" @click="restore">Restore it</button>
    </div>
    <output class="badge">
      page <strong>{{ currentPageIndex + 1 }}</strong> ·
      <strong>{{ Math.round(zoomLevel * 100) }}%</strong>
    </output>
  </div>
</template>
```

> **Try it:** scroll and zoom somewhere else, then press **Go back to page 1**.

## Two views of one document

Register the Stage twice, with its own id and token, for two independent views of the same
document, such as a thumbnail strip next to the main view:

**`thumbs-token.ts`**

```typescript
import { createCapabilityToken } from '@embedpdf/vue/runtime';
import { stagePlugin, type StageCapability } from '@embedpdf/vue/stage';

export const ThumbsToken = createCapabilityToken<StageCapability>('stage-thumbs');

export const plugins = [
  stagePlugin(), // the main view
  stagePlugin({ id: 'stage-thumbs', token: ThumbsToken, layout: 'grid', zoom: { pageWidth: 120 } }),
];
```

Pass the token to the component and the composables: `<Stage :token="ThumbsToken">` and
`useStage(ThumbsToken)`; the state and settings take it after the selector,
`useStageState(select, ThumbsToken)`. Each view has its own zoom and layout; both show the same live document.

**`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 { localEngine } from '@embedpdf/engine';
import { OverviewToken } from './overview-token';
import Views from './Views.vue';

import '../two-views.css';

const engine = localEngine();

const plugins = [
  stagePlugin(), // the main view
  stagePlugin({
    id: 'stage-overview',
    token: OverviewToken,
    layout: 'grid',
    columns: 'auto',
    zoom: { pageWidth: 72 },
    gap: { px: 10 },
    padding: 10,
    interaction: false, // a drag only scrolls it
    // A wide, short box (a phone) lines the pages up in one row.
    responsive: [{ when: { orientation: 'landscape' }, settings: { layout: 'horizontal' } }],
  }),
  renderPlugin(),
];

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>
      <Views />
    </DocumentGate>
  </Viewer>
</template>
```

**`two-views.css`**

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

/* Two views side by side; on a narrow screen the overview becomes a row on top */
.views {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.view {
  flex: 1 1 100%;
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

.zoom-bar {
  display: flex;
  align-items: center;
  gap: 6px;
}

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

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  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;
}

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

/* A page of the overview is a button: it moves the main view there */
.page-button {
  position: absolute;
  inset: 0;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

.page-button[aria-current='true'] {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

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

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

.overview .stage {
  height: 140px;
}

@container (min-width: 560px) {
  .overview {
    flex: 0 0 180px;
  }

  .main {
    flex: 1 1 0;
  }

  .overview .stage {
    height: 420px;
  }
}

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

**`overview-token.ts`**

```typescript
import { createCapabilityToken } from '@embedpdf/vue/runtime';
import type { StageCapability } from '@embedpdf/vue/stage';

// A second view of the same document: its own id and token, its own zoom and layout.
export const OverviewToken = createCapabilityToken<StageCapability>('stage-overview');
```

**`Views.vue`**

```vue
<script setup lang="ts">
import { RenderLayer } from '@embedpdf/vue/render';
import { Stage, useStage, useStageState } from '@embedpdf/vue/stage';
import { OverviewToken } from './overview-token';
import ZoomBar from './ZoomBar.vue';

const main = useStage();
const current = useStageState((state) => state.currentPageIndex);
</script>

<template>
  <div class="views">
    <section class="view overview">
      <ZoomBar label="Overview" :token="OverviewToken" />
      <Stage :token="OverviewToken" class="stage">
        <template #page="{ page }">
          <button
            type="button"
            class="page-button"
            :aria-label="`Go to page ${page.pageIndex + 1}`"
            :aria-current="page.pageIndex === current"
            @click="main.goToPage(page.ref)"
          >
            <RenderLayer />
          </button>
        </template>
      </Stage>
    </section>
    <section class="view main">
      <ZoomBar label="Main view" />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </section>
  </div>
</template>
```

**`ZoomBar.vue`**

```vue
<script setup lang="ts">
import { useStage, useStageState } from '@embedpdf/vue/stage';
import type { OverviewToken } from './overview-token';

const props = defineProps<{ label: string; token?: typeof OverviewToken }>();

// A getter, so the bar follows its `token` prop.
const stage = useStage(() => props.token);
const zoomLevel = useStageState((state) => state.zoomLevel, () => props.token);
</script>

<template>
  <div class="zoom-bar">
    <span class="caption">{{ label }}</span>
    <button type="button" class="button" aria-label="Zoom out" @click="stage.zoomOut()">−</button>
    <output class="readout">{{ Math.round(zoomLevel * 100) }}%</output>
    <button type="button" class="button" aria-label="Zoom in" @click="stage.zoomIn()">+</button>
  </div>
</template>
```

A thumbnail strip should usually not start text selection or drawing when someone drags it. Turn
off its `interaction` setting; it keeps its own drag-to-scroll:

```ts
stagePlugin({
  id: 'stage-thumbs',
  token: ThumbsToken,
  layout: 'grid',
  interaction: false, // a drag doesn't select text or draw
});
```

## Methods

Everything `useStage()` gives you.

| Method                                                                                         | What it does                                                                                                                                                                 |
| :--------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `zoomIn()`, `zoomOut()`                                                                        | Zooms one step in or out, around the [`zoomAlign`](#make-it-feel-right) point. [Zoom](#zoom)                                                                                 |
| `fitWidth()`, `fitPage()`, `fitAll()`, `fitAutomatic()`                                        | Switches to a fit mode, which keeps applying when the window resizes                                                                                                         |
| `zoomTo(zoom, { around? })`                                                                    | Zooms to a level (`1.5`), a mode (`{ mode: 'fit-width' }`) or a page size (`{ pageWidth }`, `{ pageHeight }`). `around` is a point in the Stage that stays put.              |
| `zoomBy(factor, { around? })`                                                                  | Multiplies the zoom: `1.25` is 25% closer                                                                                                                                    |
| `goToPage(page, options?)`                                                                     | Goes to a page, by its ref or its index. [Move through pages](#move-through-pages)                                                                                           |
| `nextPage(options?)`, `previousPage(options?)`                                                 | Goes to the next or previous page, or a whole spread when two facing pages fit                                                                                               |
| `goToFirstPage(options?)`, `goToLastPage(options?)`                                            | Goes to the first or the last page                                                                                                                                           |
| `canGoNext()`, `canGoPrevious()`                                                               | Whether there's a page after or before the current one, to disable your buttons                                                                                              |
| `reveal(page, options?)`                                                                       | Brings a page into view, scrolling only as far as needed. With `rect`, `zoom` or `anchor`, it lands on that spot. [Jump to an exact spot](#jump-to-an-exact-spot)            |
| `goToDestination(destination)`                                                                 | Goes where a link, a bookmark or the document's opening view points. [Follow a destination](#follow-a-destination)                                                           |
| `scrollTo({ left, top })`, `scrollBy({ left, top })`                                           | Scrolls like a scrolling element does, in screen pixels. It jumps unless you pass `behavior: 'smooth'`. [Scrollbars](#scrollbars)                                            |
| `panBy(x, y)`                                                                                  | Moves the view by `x` and `y` screen pixels                                                                                                                                  |
| `resetView()`                                                                                  | Jumps back to the first page and keeps the zoom mode                                                                                                                         |
| `isMoving()`, `stopMotion()`                                                                   | Whether a glide or a fling is running, and stopping it where it is                                                                                                           |
| `getCamera()`, `setCamera(camera)`                                                             | Where the view is, as `{ x, y, zoom }`, and moving it there at once. [Every scroll frame](#every-scroll-frame)                                                               |
| `setViewRotation(rotation)`, `rotateViewBy(degrees)`                                           | Turns how the pages are shown: to `0`, `90`, `180` or `270`, or a quarter turn from here with `90` or `-90`. [Rotate the view](#rotate-the-view)                             |
| `getViewpoint()`                                                                               | What the user is looking at on the current page, with the zoom, to pass back to `goToPage(page, { viewpoint })`. [Remember where the user was](#remember-where-the-user-was) |
| `getViewState()`, `applyViewState(view)`                                                       | Every setting and the position as one object you can store, and restoring it. The responsive rules apply again on top.                                                       |
| `getSettings()`, `updateSettings(changes)`, `resetSettings()`                                  | Reads, changes and resets the live settings, keeping you on the same page. [Settings](#settings)                                                                             |
| `setResponsiveRules(rules)`                                                                    | Replaces the responsive rules while the app runs. [Adapt to the space you have](#adapt-to-the-space-you-have)                                                                |
| `listVisiblePages()`, `isPageVisible(page)`                                                    | The pages on screen, each with its box in the Stage and the part of it that shows, and whether one page is on screen                                                         |
| `listCurrentItemPages()`                                                                       | The pages of the current spread, or the current page alone                                                                                                                   |
| `getPageAt(point)`                                                                             | The page under a point in the Stage and the point on that page, or `null` over the space between pages                                                                       |
| `viewportToPage(page, point)`, `pageToViewport(page, point)`, `pageRectToViewport(page, rect)` | Converts a point between the Stage and a page, and a box on a page to the Stage. `null` when the page isn't laid out. [Page coordinates](#page-coordinates)                  |
| `getPageFrame(page)`                                                                           | Where a page is laid out in the Stage, on screen or not, or `null` before it's placed                                                                                        |
| `getViewportSize()`                                                                            | The Stage's size in screen pixels, as last measured                                                                                                                          |

The page moves (`goToPage()`, `nextPage()`, `previousPage()`, `goToFirstPage()`, `goToLastPage()`)
take `{ behavior, arrivalAlign, viewpoint }`: `behavior` is the [`scrollBehavior`
setting](#settings) for one call, `arrivalAlign` is [where the page lands](#make-it-feel-right), and
`viewpoint` is a [saved spot](#remember-where-the-user-was) to go back to. `reveal()` and
`goToDestination()` take `behavior` too.

## State

Everything `useStageState()` 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                                           |
| :----------------- | :--------------------------------------------------------------------------------- | :-------------------------------------------------- |
| `zoomLevel`        | The zoom as a number: `1` is 100%                                                  | `getZoomLevel()`                                    |
| `zoomMode`         | The fit mode, such as `'fit-width'`, or `'custom'` after a pinch or an exact level | `getZoomMode()`                                     |
| `currentPageIndex` | The current page's place in the document, from 0                                   | `getCurrentPageIndex()`                             |
| `currentPage`      | The current page's ref, or `null` before the document has pages                    | `getCurrentPage()`                                  |
| `pageCount`        | How many pages the document has                                                    | `getPageCount()`                                    |
| `viewRotation`     | How the view is turned: `0`, `90`, `180` or `270`                                  | `getViewRotation()`                                 |
| `activeRules`      | The names of the responsive rules that apply now, in order                         | `listActiveRules()`, or `matchesRule(name)` for one |

The settings come from `useStageSettings()`, and the document's pages from `usePageList()`.

## Settings

Set them when you register the plugin, and change them while the app runs with
`stage.updateSettings({ … })`. `stage.resetSettings()` goes back to what you registered, and
`useStageSettings()` gives you the current settings.

| Setting             | What it does                                                                                    | Default                        |
| :------------------ | :---------------------------------------------------------------------------------------------- | :----------------------------- |
| `flow`              | Scroll every page, or one page or spread at a time                                              | `'continuous'`                 |
| `layout`            | `'vertical'`, `'horizontal'` or `'grid'`                                                        | `'vertical'`                   |
| `spread`            | Facing pages: `'none'`, `'odd'`, `'even'`                                                       | `'none'`                       |
| `sizing`            | `'intrinsic'` (true sizes) or `'uniform'` (equal widths)                                        | `'intrinsic'`                  |
| `columns`           | Grid columns: `'square'`, `'auto'` or a number                                                  | `'square'`                     |
| `zoom`              | A mode, a level, `{ pageWidth }` or `{ pageHeight }`                                            | `{ mode: 'automatic' }`        |
| `padding`           | Space around the pages, in screen pixels                                                        | `24` (`4` when compact)        |
| `gap`               | Space between pages: a number (grows with zoom) or `{ px }`                                     | `16`                           |
| `pageFrame`         | Space reserved around each page for your labels, in screen pixels                               | `0` on every side              |
| `bounded`           | Keep panning within the pages; `false` for an endless canvas                                    | `true`                         |
| `direction`         | Reading direction: `'rtl'` flips the page order and spreads                                     | `'ltr'`                        |
| `arrivalAlign`      | Where a page lands when you go to it                                                            | `{ x: 'start', y: 'start' }`   |
| `zoomAlign`         | What stays put when you zoom with a button                                                      | `{ x: 'center', y: 'center' }` |
| `anchorAlign`       | What stays put when the window resizes or the layout changes                                    | `{ x: 'start', y: 'start' }`   |
| `fitAlign`          | Where the content rests when everything fits                                                    | `{ x: 'center', y: 'center' }` |
| `viewRotation`      | How the pages are turned: `0`, `90`, `180`, `270`                                               | `0`                            |
| `scrollBehavior`    | Whether going to a page glides or jumps: `'smooth'`, `'instant'`                                | `'smooth'`                     |
| `interaction`       | Drags reach the active tool, such as text selection; `false` for a view that only scrolls       | `true`                         |
| `panFallback`       | A drag in the space between pages scrolls, whatever the tool                                    | `true`                         |
| `zoomGestures`      | A pinch or Ctrl+wheel zooms; `false` keeps the zoom fixed                                       | `true`                         |
| `viewUnitsPerPoint` | Screen pixels per PDF point at 100%, so 100% is the page's real size; a native app sets its own | `96 / 72` on the web           |

Set once, when you register the plugin:

| Setting       | What it does                                   | Default            |
| :------------ | :--------------------------------------------- | :----------------- |
| `responsive`  | Rules that change settings by the Stage's size | one `compact` rule |
| `id`, `token` | For a second view of the same document         | the main view      |

## Events

**`page-analytics.vue`**

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

useStageEvent(
  (stage) => stage.onPageChanged,
  ({ pageIndex }) => analytics.track('page_view', { page: pageIndex + 1 }),
);
</script>

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

**`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 { localEngine } from '@embedpdf/engine';
import EventLog from './EventLog.vue';

import '../events.css';

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

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>
      <div class="layout">
        <EventLog />
        <Stage class="stage">
          <template #page><RenderLayer /></template>
        </Stage>
      </div>
    </DocumentGate>
  </Viewer>
</template>
```

**`events.css`**

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

/* The log beside the pages; above them on a narrow screen */
.layout {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.log {
  flex: 1 1 100%;
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-height: 120px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.entry {
  display: flex;
  flex-direction: column;
  gap: 2px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 7px 10px;
  box-shadow: 0 1px 2px var(--shadow);
}

/* The newest event stands out */
.entry:first-child {
  background: var(--tint);
  border-color: var(--accent);
}

.name {
  font-size: 12px;
  font-weight: 600;
  color: var(--accent);
}

.detail {
  font-size: 12.5px;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* The pages */
.stage {
  flex: 1 1 100%;
  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);
}

@container (min-width: 600px) {
  .log {
    flex: 0 0 230px;
  }

  .stage {
    flex: 1 1 0;
    min-width: 0;
  }
}

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

**`EventLog.vue`**

```vue
<!-- The latest stage events, newest first. Scroll, zoom or resize to add more. -->
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useStage, useStageEvent } from '@embedpdf/vue/stage';

interface Entry {
  id: number;
  event: string;
  detail: string;
}

const stage = useStage();
const entries = ref<Entry[]>([]);
let count = 0;
const log = (event: string, detail: string) => {
  entries.value = [{ id: count++, event, detail }, ...entries.value].slice(0, 6);
};

useStageEvent(
  (stage) => stage.onPageChanged,
  ({ pageIndex, previousPageIndex }) =>
    log('onPageChanged', `page ${previousPageIndex + 1} → ${pageIndex + 1}`),
);
useStageEvent(
  (stage) => stage.onZoomChanged,
  ({ level, mode }) => log('onZoomChanged', `${Math.round(level * 100)}%, ${mode}`),
);
useStageEvent(
  (stage) => stage.onMotionEnded,
  ({ camera }) => log('onMotionEnded', `at rest, ${Math.round(camera.zoom * 100)}%`),
);
useStageEvent(
  (stage) => stage.onViewportChanged,
  ({ size }) => log('onViewportChanged', `${Math.round(size.width)} × ${Math.round(size.height)}`),
);

// Glide to the second page on load, so the log has something to show.
onMounted(() => stage.goToPage(1));
</script>

<template>
  <ol class="log" aria-live="polite">
    <li v-for="entry in entries" :key="entry.id" class="entry">
      <code class="name">{{ entry.event }}</code>
      <span class="detail">{{ entry.detail }}</span>
    </li>
  </ol>
</template>
```

| Event               | Fires when                                          | Carries                                  |
| :------------------ | :-------------------------------------------------- | :--------------------------------------- |
| `onPageChanged`     | Another page becomes the current one                | `page`, `pageIndex`, `previousPageIndex` |
| `onZoomChanged`     | The zoom level changed                              | `level`, `previousLevel`, `mode`         |
| `onCameraChanged`   | The view moved, on every frame of a scroll or zoom  | `camera`                                 |
| `onMotionEnded`     | Scrolling, a fling or an animated move came to rest | `camera`                                 |
| `onViewportChanged` | The Stage itself changed size                       | `size`                                   |
| `onSettingsChanged` | A setting changed, by you or by a responsive rule   | `settings`, `changed`                    |

## Good to know

### What "current page" means

The current page is the one that fills most of the view, and with spreads, the first of the
spread. It changes as you scroll, before the scroll comes to rest.

### Every scroll frame

`onCameraChanged` fires on every frame of a scroll or zoom. Use it for things that must track the
view exactly, and `onMotionEnded` for anything expensive.

### Page coordinates

Positions you pass to `reveal()` and `goToDestination()` are in points from the top-left of the page,
like every position in EmbedPDF. A destination from another PDF tool uses the file's own numbers;
turn it into this form with `pageDestinationOf(destination, page.pdfCropBox)` from the engine. See
[Coordinates](../concepts/coordinates).

### How pages stay sharp

The Stage lays the pages out; the [render plugin](render) draws them, at every zoom, within the
memory budget you set.
