---
title: "Single pages & thumbnails — React"
description: "Show one page on its own, anywhere in your app, and build a thumbnail strip that follows the reader."
framework: "React"
source: "https://www.embedpdf.com/docs/headless/react/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.tsx`**

```tsx
import { PageView } from '@embedpdf/react/page-view';
import { RenderLayer } from '@embedpdf/react/render';
import type { PageRef } from '@embedpdf/react/runtime';

export function PagePreview({ page }: { page: PageRef }) {
  return (
    <PageView page={page} width={240}>
      <RenderLayer />
    </PageView>
  );
}
```

**`basic.tsx`**

```tsx
import { useState } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { PageView } from '@embedpdf/react/page-view';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

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

// A card that shows one page, like a preview next to a search result or a comment.
function PageCard() {
  const pages = usePageList();
  const [index, setIndex] = useState(0);
  const page = pages[index];

  return (
    <figure className="card">
      <PageView page={index} width={220} className="page">
        <RenderLayer />
      </PageView>
      <figcaption className="caption">
        <strong className="title">Page {page?.label ?? index + 1}</strong>
        {page ? (
          <span className="detail">
            {Math.round(page.size.width)} × {Math.round(page.size.height)} points
          </span>
        ) : null}
        <span className="pager">
          <button
            type="button"
            className="button"
            disabled={index === 0}
            onClick={() => setIndex(index - 1)}
          >
            ‹ Previous
          </button>
          <button
            type="button"
            className="button"
            disabled={index >= pages.length - 1}
            onClick={() => setIndex(index + 1)}
          >
            Next ›
          </button>
        </span>
      </figcaption>
    </figure>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <PageCard />
      </DocumentGate>
    </Viewer>
  );
}
```

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

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

```tsx
import { PageView } from '@embedpdf/react/page-view';
import { RenderLayer } from '@embedpdf/react/render';

export const FirstPage = () => (
  <PageView page={0} width={320}>
    <RenderLayer />
  </PageView>
);
```

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

**`page-picker.tsx`**

```tsx
import { PageView } from '@embedpdf/react/page-view';
import { RenderLayer } from '@embedpdf/react/render';
import { usePageList, type PageRef } from '@embedpdf/react/runtime';

export function PagePicker({ onPick }: { onPick: (page: PageRef) => void }) {
  const pages = usePageList();
  return pages.map((page) => (
    <button key={page.index} onClick={() => onPick(page.ref)}>
      <PageView page={page.ref} width={120}>
        <RenderLayer />
      </PageView>
      {page.label ?? page.index + 1}
    </button>
  ));
}
```

**`picker.tsx`**

```tsx
import { useState } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput, PageRef } from '@embedpdf/react/runtime';
import { PageView } from '@embedpdf/react/page-view';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

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

function PagePicker() {
  const pages = usePageList();
  const [picked, setPicked] = useState<PageRef | null>(null);
  // A ref follows its page when pages move, so it's what you keep.
  const shown = picked ?? pages[0]?.ref ?? null;

  return (
    <div className="picker">
      <div className="choices" role="listbox" aria-label="Pages">
        {pages.map((page) => (
          <button
            key={page.ref.objectNumber}
            type="button"
            role="option"
            className="choice"
            aria-selected={page.ref.objectNumber === shown?.objectNumber}
            onClick={() => setPicked(page.ref)}
          >
            <PageView page={page.ref} width={84}>
              <RenderLayer />
            </PageView>
            <span className="number">{page.label ?? page.index + 1}</span>
          </button>
        ))}
      </div>
      <div className="shown">
        {shown ? (
          <PageView page={shown} width={300}>
            <RenderLayer />
          </PageView>
        ) : null}
      </div>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <PagePicker />
      </DocumentGate>
    </Viewer>
  );
}
```

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

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/react/runtime';
import { stagePlugin, type StageCapability } from '@embedpdf/react/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.tsx`**

```tsx
import { useEffect } from 'react';
import { RenderLayer } from '@embedpdf/react/render';
import { Stage, useStage, useStageState } from '@embedpdf/react/stage';
import { ThumbsToken } from './thumbnail-strip-token';

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

  // Keep the current page's thumbnail in view as the reader moves
  useEffect(() => {
    thumbs.reveal(currentPageIndex);
  }, [thumbs, currentPageIndex]);

  return (
    <Stage
      token={ThumbsToken}
      pageChrome={(page) => <span className="label">{page.pageIndex + 1}</span>}
    >
      {(page) => (
        <button
          onClick={() => main.goToPage(page.ref)}
          aria-current={page.pageIndex === currentPageIndex}
        >
          <RenderLayer />
        </button>
      )}
    </Stage>
  );
}
```

**`thumbnail-strip.tsx`**

```tsx
import { useEffect } from 'react';
import { Viewer, DocumentGate, createCapabilityToken } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage, useStageState } from '@embedpdf/react/stage';
import type { StageCapability } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './thumbnail-strip.css';

const engine = localEngine();

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

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

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

  // Keep the current page's thumbnail in view as the reader moves.
  useEffect(() => {
    thumbs.reveal(currentPageIndex);
  }, [thumbs, currentPageIndex]);

  return (
    <Stage
      token={ThumbsToken}
      className="strip"
      pageChrome={(page) => (
        <span className="number" style={{ height: page.frame.bottom }}>
          {page.pageIndex + 1}
        </span>
      )}
    >
      {(page) => (
        <button
          type="button"
          className="thumb"
          aria-label={`Page ${page.pageIndex + 1}`}
          aria-current={page.pageIndex === currentPageIndex}
          onClick={() => main.goToPage(page.ref)}
        >
          <RenderLayer />
        </button>
      )}
    </Stage>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <div className="reader">
          <Thumbnails />
          <Stage className="stage">{() => <RenderLayer />}</Stage>
        </div>
      </DocumentGate>
    </Viewer>
  );
}
```

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

- **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`                                 |
| `documentId` | Which document                                                                 | the one in scope, else the active one |
| `fallback`   | What to show until the page is ready                                           | nothing                               |
| `pageFrame`  | Space reserved around the page for your own labels, in pixels per side         | `0`                                   |
| `pageChrome` | What to draw in that space                                                     | —                                     |

`fallback` and `pageChrome` take content, and `className` 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.
