---
title: "Single pages & thumbnails — Vue"
description: "Show one page on its own, anywhere in your app, and build a thumbnail strip that follows the reader."
framework: "Vue"
source: "https://www.embedpdf.com/docs/headless/vue/viewing/page-view"
---

# Single pages & thumbnails

`<PageView>` shows one page on its own, without scrolling or zoom: a preview in a card, a page
picker in a dialog, the page a comment belongs to. Give it a page and a width:

**`page-preview.vue`**

```vue
<script setup lang="ts">
import { PageView } from '@embedpdf/vue/page-view';
import { RenderLayer } from '@embedpdf/vue/render';
import type { PageRef } from '@embedpdf/vue/runtime';

defineProps<{ page: PageRef }>();
</script>

<template>
  <PageView :page="page" :width="240">
    <RenderLayer />
  </PageView>
</template>
```

**`App.vue`**

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

import '../basic.css';

const engine = localEngine();
// No Stage: a page on its own needs only the render plugin.
const plugins = [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>
      <PageCard />
    </DocumentGate>
  </Viewer>
</template>
```

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

/* A card with one page in it: side by side, stacked on a narrow screen */
.card {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 18px;
  margin: 0;
  padding: 18px;
  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);
}

/* The page is plain DOM: give it a place like any other element */
.page {
  flex: none;
}

.caption {
  flex: 1 1 180px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}

.title {
  font-size: 16px;
  color: var(--ink);
}

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

.pager {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 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;
}

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

**`PageCard.vue`**

```vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { usePageList } from '@embedpdf/vue/runtime';
import { PageView } from '@embedpdf/vue/page-view';
import { RenderLayer } from '@embedpdf/vue/render';

// A card that shows one page, like a preview next to a search result or a comment.
const pages = usePageList();
const index = ref(0);
const page = computed(() => pages.value[index.value]);
</script>

<template>
  <figure class="card">
    <PageView :page="index" :width="220" class="page">
      <RenderLayer />
    </PageView>
    <figcaption class="caption">
      <strong class="title">Page {{ page?.label ?? index + 1 }}</strong>
      <span v-if="page" class="detail">
        {{ Math.round(page.size.width) }} × {{ Math.round(page.size.height) }} points
      </span>
      <span class="pager">
        <button type="button" class="button" :disabled="index === 0" @click="index -= 1">
          ‹ Previous
        </button>
        <button
          type="button"
          class="button"
          :disabled="index >= pages.length - 1"
          @click="index += 1"
        >
          Next ›
        </button>
      </span>
    </figcaption>
  </figure>
</template>
```

Every layer works in `<PageView>` as it does on the Stage: selection, annotations, search
highlights. So does a menu anchored to the page. The page keeps its rotation and redraws when it
changes.

## Pick the page

`page` takes a page's `ref` or its index, so `0` is the first page:

**`first-page.vue`**

```vue
<script setup lang="ts">
import { PageView } from '@embedpdf/vue/page-view';
import { RenderLayer } from '@embedpdf/vue/render';
</script>

<template>
  <PageView :page="0" :width="320">
    <RenderLayer />
  </PageView>
</template>
```

`usePageList()` gives you every page of the document, in order, to loop over:

**`page-picker.vue`**

```vue
<script setup lang="ts">
import { PageView } from '@embedpdf/vue/page-view';
import { RenderLayer } from '@embedpdf/vue/render';
import { usePageList, type PageRef } from '@embedpdf/vue/runtime';

const emit = defineEmits<{ pick: [page: PageRef] }>();
const pages = usePageList();
</script>

<template>
  <button v-for="page in pages" :key="page.index" @click="emit('pick', page.ref)">
    <PageView :page="page.ref" :width="120">
      <RenderLayer />
    </PageView>
    {{ page.label ?? page.index + 1 }}
  </button>
</template>
```

**`App.vue`**

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

import '../picker.css';

const engine = localEngine();
const plugins = [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>
      <PagePicker />
    </DocumentGate>
  </Viewer>
</template>
```

**`picker.css`**

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

/* The pages to pick from beside the picked one; above it on a narrow screen */
.picker {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

.choices {
  flex: 1 1 100%;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 12px;
  align-content: start;
}

.choice {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
  background: none;
  border: 0;
  border-radius: 8px;
  padding: 6px 0;
  cursor: pointer;
}

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

.choice[aria-selected='true'] {
  color: var(--accent);
  background: var(--tint);
  box-shadow: inset 0 0 0 2px var(--accent);
}

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

.number {
  font-variant-numeric: tabular-nums;
}

.shown {
  flex: 1 1 100%;
  display: flex;
  justify-content: center;
  padding: 18px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
}

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

  .shown {
    flex: 0 0 auto;
  }
}

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

**`PagePicker.vue`**

```vue
<script setup lang="ts">
import { computed, shallowRef } from 'vue';
import { usePageList } from '@embedpdf/vue/runtime';
import type { PageRef } from '@embedpdf/vue/runtime';
import { PageView } from '@embedpdf/vue/page-view';
import { RenderLayer } from '@embedpdf/vue/render';

const pages = usePageList();
const picked = shallowRef<PageRef | null>(null);
// A ref follows its page when pages move, so it's what you keep.
const shown = computed(() => picked.value ?? pages.value[0]?.ref ?? null);
</script>

<template>
  <div class="picker">
    <div class="choices" role="listbox" aria-label="Pages">
      <button
        v-for="page in pages"
        :key="page.ref.objectNumber"
        type="button"
        role="option"
        class="choice"
        :aria-selected="page.ref.objectNumber === shown?.objectNumber"
        @click="picked = page.ref"
      >
        <PageView :page="page.ref" :width="84">
          <RenderLayer />
        </PageView>
        <span class="number">{{ page.label ?? page.index + 1 }}</span>
      </button>
    </div>
    <div class="shown">
      <PageView v-if="shown" :page="shown" :width="300">
        <RenderLayer />
      </PageView>
    </div>
  </div>
</template>
```

A page's `label` is the page number the PDF defines, such as `'iv'` or `'A-2'`, when it has one.

## A thumbnail strip

For a strip next to the main view, with many pages that scroll, use a second Stage rather than many
`<PageView>`s: it mounts only the thumbnails you can see, and it can follow the page the reader is on.

**`thumbnail-strip-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(),
  stagePlugin({
    id: 'stage-thumbs',
    token: ThumbsToken,
    interaction: false, // a drag doesn't select text or draw
    zoomGestures: false, // a pinch doesn't resize the thumbnails
    zoom: { pageWidth: 120 },
    gap: { px: 12 },
    pageFrame: { bottom: 20 }, // room for the page number
  }),
];
```

**`thumbnail-strip.vue`**

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

const main = useStage();
const thumbs = useStage(ThumbsToken);
const { currentPageIndex } = useStageState();

// Keep the current page's thumbnail in view as the reader moves
watch(currentPageIndex, (index) => thumbs.reveal(index), { immediate: true });
</script>

<template>
  <Stage :token="ThumbsToken">
    <template #page="{ page }">
      <button
        :aria-current="page.pageIndex === currentPageIndex"
        @click="main.goToPage(page.ref)"
      >
        <RenderLayer />
      </button>
    </template>
    <template #page-chrome="{ page }">
      <span class="label">{{ page.pageIndex + 1 }}</span>
    </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 Thumbnails from './Thumbnails.vue';
import { ThumbsToken } from './thumbs-token';

import '../thumbnail-strip.css';

const engine = localEngine();

const plugins = [
  stagePlugin(), // the main view
  stagePlugin({
    id: 'stage-thumbs',
    token: ThumbsToken,
    interaction: false, // a drag doesn't select text or draw
    zoomGestures: false, // a pinch doesn't resize the thumbnails
    zoom: { pageWidth: 96 },
    gap: { px: 12 },
    padding: 10,
    pageFrame: { bottom: 20 }, // room for the page number
    // A wide, short strip (on a phone) lines the thumbnails up in a 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>
      <div class="reader">
        <Thumbnails />
        <Stage class="stage">
          <template #page><RenderLayer /></template>
        </Stage>
      </div>
    </DocumentGate>
  </Viewer>
</template>
```

**`thumbnail-strip.css`**

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

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

.strip,
.stage {
  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);
}

.strip {
  flex: 1 1 100%;
  height: 156px;
}

.stage {
  flex: 1 1 100%;
  height: 420px;
}

@container (min-width: 560px) {
  .strip {
    flex: 0 0 140px;
    height: 420px;
  }

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

/* A thumbnail is a button over the page picture */
.thumb {
  position: absolute;
  inset: 0;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

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

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

/* The page number, in the band reserved below each thumbnail */
.number {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 600;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  pointer-events: none;
}

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

**`Thumbnails.vue`**

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

const main = useStage();
const thumbs = useStage(ThumbsToken);
const { currentPageIndex } = useStageState();

// Keep the current page's thumbnail in view as the reader moves.
watch(currentPageIndex, (index) => thumbs.reveal(index), { immediate: true });
</script>

<template>
  <Stage :token="ThumbsToken" class="strip">
    <template #page="{ page }">
      <button
        type="button"
        class="thumb"
        :aria-label="`Page ${page.pageIndex + 1}`"
        :aria-current="page.pageIndex === currentPageIndex"
        @click="main.goToPage(page.ref)"
      >
        <RenderLayer />
      </button>
    </template>
    <template #page-chrome="{ page }">
      <span class="number" :style="{ height: `${page.frame.bottom}px` }">
        {{ page.pageIndex + 1 }}
      </span>
    </template>
  </Stage>
</template>
```

**`thumbs-token.ts`**

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

// The strip is a second view of the document, with its own token and settings.
export const ThumbsToken = createCapabilityToken<StageCapability>('stage-thumbs');
```

- **Turning off `interaction`** in the strip's settings keeps a drag on it from selecting text in
  the document.
- **Turning off `zoomGestures`** keeps the thumbnails their size under a pinch.
- **`reveal()`** scrolls the strip only as far as needed, so it follows without jumping.

See [Two views of one document](stage#two-views-of-one-document) for everything a second Stage can
do.

## Props

| Prop          | What it is                                                                     | Default                               |
| :------------ | :----------------------------------------------------------------------------- | :------------------------------------ |
| `page`        | The page, as its `ref` or its index                                            | required                              |
| `width`       | The width of the page upright, in pixels: a page turned a quarter is that tall | `240`                                 |
| `document-id` | Which document                                                                 | the one in scope, else the active one |
| `fallback`    | What to show until the page is ready                                           | nothing                               |
| `page-frame`  | Space reserved around the page for your own labels, in pixels per side         | `0`                                   |
| `page-chrome` | What to draw in that space                                                     | —                                     |

`fallback` and `page-chrome` are slots, `<template #fallback>` and `<template #page-chrome>`, and
`class` and `style` go on the outer box.

## Good to know

### How pages look

A page here has the same background and shadow as on the Stage: the viewer's `page` settings, or
`--epdf-page-background` and `--epdf-page-shadow` from CSS (see [Colors & theming](../ui/theming)).

### Menus inside a page view

A menu anchored to the page, such as the selection menu, is placed above everything else on the
page, so a card or a scrolling list can't cut it off.

### Pointer input

A `<PageView>` takes pointer input for the tools when the interaction plugin is registered, so text
selection and annotation editing work in it too. On a touch screen, the Stage is the better choice:
it has the gestures.
