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:
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.
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:
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.
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.revealis gentler thangoToPage. 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.
| 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:
// 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:
paddingis 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.gapis 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.
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:
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:
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:
// 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:
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:
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 placeA single navigation can override the setting for just that call:
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:
stage.rotateView(90); // one quarter-turn clockwise from here
stage.setViewRotation(180); // or jump to an absolute rotationTo 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:
// 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:
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' |
Your feedback goes directly to the documentation team.