EmbedPDF

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 isn’t available for Svelte yet. You can read the React version in the meantime.

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 isn’t available for Svelte yet. You can read the React version in the meantime.

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.

ModeWhat it does
automaticFit page width, but never zoom past 100%. The usual default.
fit-pageFit the whole page in view.
fit-widthFill the full width.
fit-allZoom 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.

This example isn’t available for Svelte yet. You can read the React version in the meantime.

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 isn’t available for Svelte yet. You can read the React version in the meantime.
SettingWhat it decidesValues
flowScroll all pages, or one page (or spread) at a time'continuous' (default), 'paged'
layoutHow pages are arranged'vertical' (default), 'horizontal', 'grid'
spreadFacing pages side by side, like a book'none' (default), 'odd', 'even'
sizingTrue 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:

  • 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.
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 isn’t available for Svelte yet. You can read the React version in the meantime.

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 isn’t available for Svelte yet. You can read the React version in the meantime.

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:

SettingThe question it answersDefault
arrivalAlignWhere does a page land when you navigate to it?top / reading edge
zoomAlignWhat stays put when you zoom with buttons (no cursor to zoom around)?the center
anchorAlignWhat stays put when the window resizes or the layout changes?the top
fitAlignWhere 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 isn’t available for Svelte yet. You can read the React version in the meantime.

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 place

A 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:

This example isn’t available for Svelte yet. You can read the React version in the meantime.
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:

// 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.

SettingWhat it doesDefault
flowContinuous scroll or one page/spread at a time'continuous'
layout'vertical', 'horizontal', or 'grid''vertical'
spreadFacing pages: 'none', 'odd', 'even''none'
sizing'intrinsic' (true sizes) or 'uniform' (equal widths)'intrinsic'
columnsGrid columns: 'square', 'auto' (wrap to fit), or a number'square'
zoomThe zoom intent: a fit mode, { level }, or { pageWidth }{ mode: 'automatic' }
paddingSpace around the content, screen px24
gapSpace between pages: number (scales with zoom) or { px } (fixed)16
pageFrameReserved chrome bands around each page, screen px per sideall 0
boundedClamp panning to the content; false = free infinite canvastrue
directionReading direction; 'rtl' flips layout order and spread binding'ltr'
arrivalAlignWhere navigation lands the target page{ x: 'start', y: 'start' }
zoomAlignThe focal point of button/keyboard zoom{ x: 'center', y: 'center' }
anchorAlignThe point that stays put through resizes and layout changes{ x: 'start', y: 'start' }
fitAlignWhere content rests on an axis with nothing to scroll{ x: 'center', y: 'center' }
viewRotationDisplay rotation for this view: 0, 90, 180, 2700
scrollBehaviorWhether navigation glides or jumps: 'smooth', 'instant''smooth'
Was this page helpful?

Your feedback goes directly to the documentation team.