---
title: "Stage — Vue"
description: "Lay out pages and handle pan, zoom, and page navigation in your PDF viewer."
framework: "Vue"
source: "https://www.embedpdf.com/docs/headless/vue/plugins/stage"
---

# Stage

The Stage is where your document is shown. It lays pages out (vertical scroll,
facing pages, thumbnail grid), keeps only visible pages mounted, and handles
pan, zoom, and page navigation.

You build the chrome and what each page renders. The Stage handles the view.

## Your first Stage

Register two plugins: `stagePlugin()` for layout and navigation, and
`renderPlugin()` to draw page bitmaps. Put a `<Stage>` in your tree and pass
a render function for each visible page. Here, just the rendered page:

> This example is not available for Vue yet.

> **Try it:** drag to pan, and zoom with ctrl + scroll (cmd + scroll on a Mac)
> or a trackpad pinch. That works without any event handlers of your own.

What you get from this alone:

- **Virtualized pages.** Only pages in view are mounted, so a 2,000-page
  document stays as smooth as a short one.
- **Built-in gestures.** Pan, zoom around the cursor, and scroll are wired up.
- **A page you own.** `{() => <RenderLayer />}` runs once per visible page.
  Later you stack more layers there (text selection, annotations, search
  highlights) and they position themselves with the page.

## Zoom

`useZoom()` is the whole zoom API: the current level, the active fit mode, and
the actions to change them.

> This example is not available for Vue yet.

`zoom` is a plain number. `1` is 100%, `2` is 200%. And 100% means *real*
paper size: a US Letter page renders at its physical size, the same way
Acrobat does.

Fit modes keep re-applying when the window resizes. The moment the user
pinches or presses **+**, the mode becomes `custom` and their level wins.

| Mode        | What it does                                                 |
| ----------- | ------------------------------------------------------------ |
| `automatic` | Fit page width, but never zoom past 100%. The usual default. |
| `fit-page`  | Fit the whole page in view.                                  |
| `fit-width` | Fill the full width.                                         |
| `fit-all`   | Zoom out until every page is visible (a document overview).  |

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

```ts
zoomTo({ level: 1.5 }); // exactly 150%
zoomTo({ mode: 'fit-width' }); // same as fitWidth()
zoomTo({ pageWidth: 200 }); // every page 200px wide (thumbnail rails)
```

## Moving through pages

`usePages()` gives you the current page, the page count, and the ways to move:
`goToPage`, `next`, `prev`, and `reveal`.

> This example is not available for Vue yet.

> **Try it:** zoom in first, then press **Next**. The next page lands with its
> top edge at the top of the view, at every zoom level. Landing is a policy,
> not a side effect of how far you were zoomed in.

A few details that matter:

- **Pages count from 0.** Page "1" in your UI is `goToPage(0)`.
- **`next()` / `prev()` respect spreads.** When a two-page spread fits, they
  move one spread. Zoomed in, they move one page.
- **`reveal` is gentler than `goToPage`.** It scrolls only as far as needed to
  make a page visible, and does nothing if it already is. Use it for thumbnail
  or outline clicks so the view does not jump hard.

## Layouts

Page arrangement is a few settings. Change them at startup with
`stagePlugin({ … })`, or at runtime with `useLayout()` (as the demo does).
Either way, the Stage keeps you on the same page while the layout reflows.

> This example is not available for Vue yet.

| Setting  | What it decides                                     | Values                                           |
| -------- | --------------------------------------------------- | ------------------------------------------------ |
| `flow`   | Scroll all pages, 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` | True page sizes, or equal width for every page      | `'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` only applies to grid layout: `'auto'` wraps to fit, `'square'` aims
for a roughly square grid, and a number sets a fixed column count.

## Space around pages

Two settings control spacing:

- **`padding`** is space between the pages and the edge of the Stage, in screen
  pixels. Fit modes respect it, so "fit page" never touches the edges.
  Default: `24`.
- **`gap`** is space *between* pages. A plain number (`gap: 16`, the default)
  scales with zoom, as if the gap were drawn between paper pages.
  `gap: { px: 16 }` stays 16 screen pixels at every zoom, which is what you
  usually want for thumbnail rails.

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

## Labels and buttons on every page

For UI that belongs to a page but should not sit *on* it (a page number below,
a button row above), reserve space with `pageFrame`, then draw into that space
with the `pageChrome` prop:

> This example is not available for Vue yet.

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

## Scrollbars and progress

The Stage uses the same scroll vocabulary as the browser.
`scrollMetrics()` returns `scrollTop`, `scrollHeight`, and `clientHeight`
(the same numbers a DOM element would report), and `scrollTo()` /
`scrollBy()` work like `Element.scrollTo`. Anything you would build against a
scrollable div, you can build against the Stage.

There is a ready-made headless `<Scrollbar>`. The demo also builds a reading
progress bar from the raw metrics with a few lines of math:

> This example is not available for Vue yet.

The numbers stay in sync with the view: zoom in and the scroll range grows,
switch to paged flow and the bar reflects just the current page, and when
everything fits the metrics report nothing to scroll so you can hide the bar,
just like a native scrollbar.

## Jump to an exact spot

`reveal` can do more than "make this page visible". Give it a rectangle and it
becomes your "jump to search result" and "follow this link" verb:

```ts
// Show a search hit: scroll to the match, zoomed so it's comfortable to read
stage.reveal(pageIndex, {
  rect: match.rect, // a rectangle in page coordinates
  zoom: 'fit-width', // zoom so the rect spans the view — or 'keep' to not zoom
  anchor: { y: 0.35 }, // place it about a third from the top, like a browser find bar
});
```

Search hits, outline clicks, "jump to comment", PDF link destinations — they
all reduce to this one call.

## Make it feel right

When the user clicks "next page", where should that page *land*? At the top of
the view, or centered like a slide? Four alignment settings answer that kind
of question. They all use the same values (`'start'`, `'center'`, `'end'`, or
a fraction like `0.35`), set per axis:

| Setting        | The question it answers                                               | Default            |
| -------------- | --------------------------------------------------------------------- | ------------------ |
| `arrivalAlign` | Where does a page land when you navigate to it?                       | top / reading edge |
| `zoomAlign`    | What stays put when you zoom with buttons (no cursor to zoom around)? | the center         |
| `anchorAlign`  | What stays put when the window resizes or the layout changes?         | the top            |
| `fitAlign`     | Where does content rest when it fully fits (nothing to scroll)?       | centered           |

The defaults feel like reading a document. Configure nothing if that is what
you want. Set them all to center for a presentation feel, where each move
keeps the current page centered like a slide:

> This example is not available for Vue yet.

> **Try it:** click through pages with **Next**, then switch the feel and
> click again. Same buttons, different behavior.

A preset is just an object you keep and apply with `update()` from
`useStageSettings()`. The Stage does not ship named presets; your product
defines its own:

```ts
const presentation = {
  arrivalAlign: { x: 'center', y: 'center' },
  zoomAlign: { x: 'center', y: 'center' },
  anchorAlign: { x: 'center', y: 'center' },
} satisfies Partial<StageSettings>;

update(presentation); // one change, keeps your place
```

A single navigation can override the setting for just that call:

```ts
goToPage(12, { arrivalAlign: { y: 'center' } });
```

`'keep'` means "do not move this axis." With
`arrivalAlign: { x: 'keep', y: 'start' }`, someone zoomed into the left column
of a two-column paper can page forward and stay in the left column.

## Rotate the view

For tilted scans, rotate how pages are *displayed* without changing the file.
Save afterwards and the PDF is still untouched:

> This example is not available for Vue yet.

```ts
stage.rotateView(90); // one quarter-turn clockwise from here
stage.setViewRotation(180); // or jump to an absolute rotation
```

To *permanently* rotate pages and write that into the PDF, use the page-edit
plugin. That is a document edit, not a view setting. Keep the two on different
buttons.

## Remember where the user was

Capture the current view and restore it later. There are two levels:

```ts
// Per-page: capture before leaving a page, restore when coming back
const memo = stage.viewpoint();
stage.goToPage(5);
// …later…
stage.goToPage(2, { viewpoint: memo }); // same spot, same zoom

// Whole session: one serializable object with every setting and position
const saved = stage.viewState();
localStorage.setItem('view', JSON.stringify(saved));
// …next visit…
stage.applyViewState(JSON.parse(localStorage.getItem('view')!));
```

Viewpoints are resize-proof. They remember *what* you were looking at, not raw
pixel offsets, so they restore correctly even in a differently sized window.

## Two views of one document

The Stage is not a singleton. Register it twice with different ids and tokens
to get two independent views of the same document. A common case is a
thumbnail sidebar next to the main view:

```ts
import { createCapabilityToken } from '@embedpdf/core';
import type { StageCapability } from '@embedpdf/react/stage';

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

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

Every Stage component and hook accepts a `token` for which view it talks to.
`<Stage token={ThumbsToken}>` has its own zoom and layout, while both show the
same live document.

## All the settings

Every option in one place. Set any of them at startup with
`stagePlugin({ … })`, or at runtime one at a time (`setLayout(…)`) or several
at once with `update({ … })`. `update` keeps the user's place while the layout
reflows.

| Setting          | What it does                                                       | Default                        |
| ---------------- | ------------------------------------------------------------------ | ------------------------------ |
| `flow`           | Continuous scroll or one page/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'` (wrap to fit), or a number      | `'square'`                     |
| `zoom`           | The zoom intent: a fit mode, `{ level }`, or `{ pageWidth }`       | `{ mode: 'automatic' }`        |
| `padding`        | Space around the content, screen px                                | `24`                           |
| `gap`            | Space between pages: number (scales with zoom) or `{ px }` (fixed) | `16`                           |
| `pageFrame`      | Reserved chrome bands around each page, screen px per side         | all `0`                        |
| `bounded`        | Clamp panning to the content; `false` = free infinite canvas       | `true`                         |
| `direction`      | Reading direction; `'rtl'` flips layout order and spread binding   | `'ltr'`                        |
| `arrivalAlign`   | Where navigation lands the target page                             | `{ x: 'start', y: 'start' }`   |
| `zoomAlign`      | The focal point of button/keyboard zoom                            | `{ x: 'center', y: 'center' }` |
| `anchorAlign`    | The point that stays put through resizes and layout changes        | `{ x: 'start', y: 'start' }`   |
| `fitAlign`       | Where content rests on an axis with nothing to scroll              | `{ x: 'center', y: 'center' }` |
| `viewRotation`   | Display rotation for this view: `0`, `90`, `180`, `270`            | `0`                            |
| `scrollBehavior` | Whether navigation glides or jumps: `'smooth'`, `'instant'`        | `'smooth'`                     |
