EmbedPDF

Scroll, zoom & layout

The Stage is where the document is shown. It lays out the pages (scrolling, facing pages, a grid), keeps only the visible ones mounted, and handles scrolling, zoom and page navigation, with the mouse, the keyboard and on touch screens. You decide what each page draws and what the controls around it look like.

Register stagePlugin() with renderPlugin(), put <Stage> in your layout, and give it a render function that draws one page:

Loading live preview…

import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './basic.css';

const engine = localEngine();
const plugins = [stagePlugin(), 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()) };
};

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

Try it: drag to pan, and zoom with Ctrl and the wheel (Cmd on a Mac) or a trackpad pinch. On a phone, flick to scroll, pinch to zoom, and double-tap to zoom in.

  • Only visible pages are mounted, so a 2,000-page document scrolls as smoothly as a short one.
  • Gestures work with no code of your own: pan, zoom around the pointer, momentum scrolling and pinch.
  • Each page is yours. The render function runs once per visible page. Stack more layers in it (text selection, annotations, search highlights) and they move with the page.

Zoom#

useStageState() gives you the zoom to show, and useStage() the calls to change it:

Loading live preview…

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

import './zoom.css';

const engine = localEngine();
const plugins = [stagePlugin(), 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 ZoomToolbar() {
  const stage = useStage();
  const { zoomLevel, zoomMode } = useStageState();

  return (
    <div className="toolbar">
      <button
        type="button"
        className="button"
        aria-label="Zoom out"
        onClick={() => stage.zoomOut()}
      >
        −
      </button>
      <output className="readout">{Math.round(zoomLevel * 100)}%</output>
      <button type="button" className="button" aria-label="Zoom in" onClick={() => stage.zoomIn()}>
        +
      </button>
      <div className="segmented" role="group" aria-label="Fit">
        <button
          type="button"
          aria-pressed={zoomMode === 'automatic'}
          onClick={() => stage.fitAutomatic()}
        >
          Automatic
        </button>
        <button
          type="button"
          aria-pressed={zoomMode === 'fit-page'}
          onClick={() => stage.fitPage()}
        >
          Fit page
        </button>
        <button
          type="button"
          aria-pressed={zoomMode === 'fit-width'}
          onClick={() => stage.fitWidth()}
        >
          Fit width
        </button>
      </div>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <ZoomToolbar />
        <Stage className="stage">{() => <RenderLayer />}</Stage>
      </DocumentGate>
    </Viewer>
  );
}
zoom-controls.tsx
import { useStage, useStageState } from '@embedpdf/react/stage';

export function ZoomControls() {
  const stage = useStage();
  const { zoomLevel, zoomMode } = useStageState();

  return (
    <div>
      <button onClick={() => stage.zoomOut()}>−</button>
      <span>{Math.round(zoomLevel * 100)}%</span>
      <button onClick={() => stage.zoomIn()}>+</button>
      <button onClick={() => stage.fitWidth()} aria-pressed={zoomMode === 'fit-width'}>
        Fit width
      </button>
    </div>
  );
}

zoomLevel is a plain number: 1 is 100%, 2 is 200%. And 100% means the page’s real size: a Letter page shows as big as the paper, as it does in Acrobat.

A fit mode keeps applying when the window resizes. As soon as the user pinches or presses +, the mode becomes 'custom' and their zoom stays.

ModeWhat it does
'automatic'Fits the page width, but never zooms past 100%. The default.
'fit-page'Fits the whole page in view.
'fit-width'Fills the full width.
'fit-all'Zooms out until every page is visible.

For an exact level, a mode or a fixed page size, use zoomTo:

stage.zoomTo(1.5); // exactly 150%
stage.zoomTo({ mode: 'fit-width' });
stage.zoomTo({ pageWidth: 200 }); // every page 200 pixels wide, for thumbnails
stage.zoomBy(1.25, { around: { x: 400, y: 300 } }); // 25% closer, around a point in the Stage

Move through pages#

page-navigation.tsx
import { useStage, useStageState } from '@embedpdf/react/stage';

export function PageNavigation() {
  const stage = useStage();
  const { currentPageIndex, pageCount } = useStageState();

  return (
    <nav>
      <button onClick={() => stage.previousPage()}>Previous</button>
      <span>
        {currentPageIndex + 1} of {pageCount}
      </span>
      <button onClick={() => stage.nextPage()}>Next</button>
    </nav>
  );
}
stage.goToPage(4); // the fifth page
stage.goToPage(page.ref); // or by its ref
stage.goToFirstPage();
stage.goToLastPage();

Loading live preview…

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

import './navigation.css';

const engine = localEngine();
const plugins = [stagePlugin(), 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 PageToolbar() {
  const stage = useStage();
  const { currentPageIndex, pageCount } = useStageState();
  const [typed, setTyped] = useState('');

  // People count from 1, an index from 0. An index past the end goes to the last page.
  const jump = () => {
    const number = Number(typed);
    if (Number.isInteger(number) && number >= 1) stage.goToPage(number - 1);
    setTyped('');
  };

  return (
    <div className="toolbar">
      <button
        type="button"
        className="button"
        disabled={!stage.canGoPrevious()}
        onClick={() => stage.previousPage()}
      >
        ‹ Previous
      </button>
      <output className="badge">
        Page{' '}
        <strong>
          {currentPageIndex + 1} / {pageCount}
        </strong>
      </output>
      <button
        type="button"
        className="button"
        disabled={!stage.canGoNext()}
        onClick={() => stage.nextPage()}
      >
        Next ›
      </button>
      <input
        className="field"
        inputMode="numeric"
        aria-label="Go to page"
        placeholder="Go to page…"
        value={typed}
        onChange={(event) => setTyped(event.target.value)}
        onKeyDown={(event) => event.key === 'Enter' && jump()}
      />
    </div>
  );
}

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

Try it: zoom in, then press Next. The next page lands with its top edge at the top of the view, at every zoom.

  • A page is its ref or its index. Every call that takes a page takes either. An index counts from 0, so page “1” in your UI is goToPage(0), and an index past the end goes to the last page.
  • nextPage() and previousPage() follow spreads. When two facing pages fit, they move a whole spread; zoomed in, one page.
  • reveal() scrolls only as far as needed. It makes a page visible and does nothing if it already is. Use it for a thumbnail strip that follows along without jumping.
  • You can navigate as soon as the document opens. A call made before the Stage is on screen waits for it, and the last one is where the Stage opens, without scrolling there.

Layouts#

The arrangement of the pages is four settings. Set them when you register the plugin, or change them while the app runs. The Stage keeps you on the same page while it reflows:

Loading live preview…

import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage, useStageSettings } from '@embedpdf/react/stage';
import type { FlowMode, LayoutKind, SpreadMode } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './layouts.css';

const engine = localEngine();
// Facing pages from the start, like a book.
const plugins = [stagePlugin({ spread: 'odd' }), 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 LayoutControls() {
  const stage = useStage();
  const { flow, layout, spread } = useStageSettings();

  return (
    <div className="toolbar">
      <label className="label">
        Flow
        <select
          className="select"
          value={flow}
          onChange={(event) => stage.updateSettings({ flow: event.target.value as FlowMode })}
        >
          <option value="continuous">continuous</option>
          <option value="paged">paged</option>
        </select>
      </label>
      <label className="label">
        Layout
        <select
          className="select"
          value={layout}
          onChange={(event) => stage.updateSettings({ layout: event.target.value as LayoutKind })}
        >
          <option value="vertical">vertical</option>
          <option value="horizontal">horizontal</option>
          <option value="grid">grid</option>
        </select>
      </label>
      <label className="label">
        Spread
        <select
          className="select"
          value={spread}
          onChange={(event) => stage.updateSettings({ spread: event.target.value as SpreadMode })}
        >
          <option value="none">none</option>
          <option value="odd">odd</option>
          <option value="even">even</option>
        </select>
      </label>
      <div className="pager">
        <button
          type="button"
          className="button"
          aria-label="Previous"
          onClick={() => stage.previousPage()}
        >
          ‹
        </button>
        <button type="button" className="button" aria-label="Next" onClick={() => stage.nextPage()}>
          ›
        </button>
      </div>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <LayoutControls />
        <Stage className="stage">{() => <RenderLayer />}</Stage>
      </DocumentGate>
    </Viewer>
  );
}
stage.updateSettings({ layout: 'horizontal', spread: 'odd' });
SettingWhat it decidesValues
flowScroll every page, 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'
sizingEach page at its true size, or every page equally wide'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 applies to the grid: 'auto' wraps to fit, 'square' aims for a square grid, and a number fixes the count.

Space around pages#

  • padding is the space between the pages and the edge of the Stage, in screen pixels. Fit modes respect it, so “fit page” never touches the edges. It’s 24, or 4 on narrow screens.
  • gap is the space between pages. A number (16, the default) grows with the zoom, like the space between sheets of paper. { px: 16 } stays 16 screen pixels at every zoom, which suits a thumbnail strip.
stagePlugin({ padding: 32, gap: { px: 12 } });

Loading live preview…

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

import './spacing.css';

const engine = localEngine();
// No responsive rules, so the padding you set applies at every width.
const plugins = [stagePlugin({ padding: 32, gap: { px: 12 }, responsive: [] }), 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 SpacingControls() {
  const stage = useStage();
  const { padding, gap } = useStageSettings();
  const zoomLevel = useStageState((state) => state.zoomLevel);
  // A number grows with the zoom; { px } stays the same on screen.
  const onScreen = typeof gap !== 'number';
  const gapSize = typeof gap === 'number' ? gap : gap.px;

  return (
    <div className="toolbar">
      <label className="label">
        Padding
        <input
          className="range"
          type="range"
          min={0}
          max={64}
          value={padding}
          onChange={(event) => stage.updateSettings({ padding: Number(event.target.value) })}
        />
        <output className="value">{padding}</output>
      </label>
      <label className="label">
        Gap
        <input
          className="range"
          type="range"
          min={0}
          max={64}
          value={gapSize}
          onChange={(event) => {
            const size = Number(event.target.value);
            stage.updateSettings({ gap: onScreen ? { px: size } : size });
          }}
        />
        <output className="value">{gapSize}</output>
      </label>
      <div className="segmented" role="group" aria-label="Gap unit">
        <button
          type="button"
          aria-pressed={onScreen}
          onClick={() => stage.updateSettings({ gap: { px: gapSize } })}
        >
          Screen pixels
        </button>
        <button
          type="button"
          aria-pressed={!onScreen}
          onClick={() => stage.updateSettings({ gap: gapSize })}
        >
          Grows with zoom
        </button>
      </div>
      <div className="zoom">
        <button
          type="button"
          className="button"
          aria-label="Zoom out"
          onClick={() => stage.zoomOut()}
        >
          −
        </button>
        <output className="readout">{Math.round(zoomLevel * 100)}%</output>
        <button
          type="button"
          className="button"
          aria-label="Zoom in"
          onClick={() => stage.zoomIn()}
        >
          +
        </button>
      </div>
    </div>
  );
}

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

Adapt to the space you have#

A generous margin on a desktop wastes half a phone screen. Settings can depend on how much room the Stage has, like a CSS container query:

stagePlugin({
  padding: 24,
  spread: 'odd',
  responsive: [
    { name: 'compact', when: { maxWidth: 600 }, settings: { padding: 4 } },
    { when: { orientation: 'portrait' }, settings: { spread: 'none' } },
  ],
});

Every rule whose when matches applies, in order, and a later rule wins on the settings they share. when takes minWidth, maxWidth, minHeight, maxHeight and orientation, all combined with and, or a function for anything else: (box) => box.width / box.height > 2.

  • It measures the Stage, not the device. A 500-pixel pane on a desktop is compact too, and two Stages in one window decide on their own.
  • A rule applies when the size crosses it. In between, the user is in charge: pinch to 300%, resize the window, and the zoom stays while the padding rule still applies.

Loading live preview…

import { useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import {
  Stage,
  stagePlugin,
  useStage,
  useStageSettings,
  useStageState,
} from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './responsive.css';

const engine = localEngine();

// Facing pages with a roomy margin, and one rule for a narrow Stage: a thin
// margin and one page at a time. The rule has a name, so the UI can read it too.
const plugins = [
  stagePlugin({
    padding: 24,
    spread: 'odd',
    responsive: [
      { name: 'compact', when: { maxWidth: 600 }, settings: { padding: 4, spread: 'none' } },
    ],
  }),
  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 Toolbar({ narrow, onNarrow }: { narrow: boolean; onNarrow: (narrow: boolean) => void }) {
  const stage = useStage();
  // One breakpoint drives both the layout and this toolbar.
  const compact = useStageState((state) => state.activeRules.includes('compact'));
  const { padding, spread } = useStageSettings();

  return (
    <div className="toolbar">
      <div className="segmented" role="group" aria-label="Stage width">
        <button type="button" aria-pressed={!narrow} onClick={() => onNarrow(false)}>
          Full width
        </button>
        <button type="button" aria-pressed={narrow} onClick={() => onNarrow(true)}>
          360 px
        </button>
      </div>
      <output className="badge" data-on={compact}>
        compact <strong>{compact ? 'on' : 'off'}</strong>
      </output>
      <output className="badge">
        padding <strong>{padding}</strong> · spread <strong>{spread}</strong>
      </output>
      <div className="pager">
        <button type="button" className="button" onClick={() => stage.previousPage()}>
          {compact ? '‹' : '‹ Previous'}
        </button>
        <button type="button" className="button" onClick={() => stage.nextPage()}>
          {compact ? '›' : 'Next ›'}
        </button>
      </div>
    </div>
  );
}

export default function App() {
  const [narrow, setNarrow] = useState(false);

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Toolbar narrow={narrow} onNarrow={setNarrow} />
        <div className="frame" data-width={narrow ? 'narrow' : 'full'}>
          <Stage className="stage">{() => <RenderLayer />}</Stage>
        </div>
      </DocumentGate>
    </Viewer>
  );
}

Try it: switch the Stage to 360 pixels. The rule turns on, the facing pages become one, and the toolbar’s buttons get shorter, all from the same breakpoint.

Out of the box there’s one rule, the compact margin above. Pass your own list to replace it, or responsive: [] for none. stage.setResponsiveRules([…]) changes the rules while the app runs.

A named rule is also something your UI can read, so one breakpoint drives both the layout and your own chrome:

compact-layout.tsx
import { useStageState } from '@embedpdf/react/stage';
import { BottomSheet, SidePanel } from './panels';

export function Panels() {
  const compact = useStageState((state) => state.activeRules.includes('compact'));

  return compact ? <BottomSheet /> : <SidePanel />;
}

On touch screens#

Touch works out of the box, tuned to feel like the platform rather than a web page:

  • One finger scrolls, with momentum: flick and the page glides to a stop.
  • Two fingers pinch-zoom around the point between them, and pan at the same time.
  • Double-tap zooms in steps: the whole page, the text at full width, closer, and back out.
  • Edges are elastic. Pulling past the end stretches and springs back.
  • Long-press selects a word when the Selection plugin is registered.

For a Stage that should scroll but not zoom under a pinch or Ctrl+wheel, such as a thumbnail strip, set its zoomGestures setting to false.

Labels and buttons on every page#

For UI that belongs to a page but shouldn’t sit on it, like a page number below or a row of buttons above, reserve space with pageFrame and draw into it with the pageChrome prop:

Loading live preview…

import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './page-labels.css';

const engine = localEngine();

// Reserve a 26px band below every page: the label lives there, so it never
// covers the page and keeps its size when you zoom.
const plugins = [stagePlugin({ pageFrame: { bottom: 26 } }), 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()) };
};

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Stage
          className="stage"
          pageChrome={(page) => (
            <div className="page-label" style={{ height: page.frame.bottom }}>
              Page {page.pageIndex + 1}
            </div>
          )}
        >
          {() => <RenderLayer />}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

The reserved bands are in screen pixels, so labels keep their size at every zoom. They count as part of the page: “fit page” includes them, and going to a page shows its label.

Each page has a drop shadow so it reads as paper. It’s the viewer’s page.shadow setting ('none' for no shadow), next to page.background, the color of a page before its picture arrives. Both can also come from CSS, which wins over the setting:

.pdf-viewer {
  --epdf-page-shadow: 0 2px 8px rgb(0 0 0 / 0.25); /* or none */
}

See Colors & theming.

Scrollbars#

<Scrollbar> is a scrollbar for the Stage that you style yourself: drag the thumb, click the track to page, and it hides when the pointer leaves, like a macOS scrollbar:

Loading live preview…

import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Scrollbar, Stage, stagePlugin, useScrollMetrics } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './scrollbar.css';

const engine = localEngine();
const plugins = [stagePlugin(), 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 reading-progress bar, from the same numbers a scrollbar uses.
function ReadingProgress() {
  const { scrollTop, scrollHeight, clientHeight } = useScrollMetrics();
  const travel = scrollHeight - clientHeight;
  const progress = travel > 0 ? Math.round((scrollTop / travel) * 100) : 0;

  return (
    <div
      className="progress"
      role="progressbar"
      aria-label="Reading progress"
      aria-valuenow={progress}
    >
      <div className="progress-fill" style={{ width: `${progress}%` }} />
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <ReadingProgress />
        <Stage
          className="stage"
          overlay={
            <Scrollbar axis="y" autoHide={1200} className="scrollbar" thumbClassName="thumb" />
          }
        >
          {() => <RenderLayer />}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}
scrollbar.tsx
import { Scrollbar } from '@embedpdf/react/stage';

export const MyScrollbar = () => (
  <Scrollbar axis="y" autoHide={1200} className="my-scrollbar" thumbClassName="my-thumb" />
);
PropWhat it isDefault
axis'x' or 'y'required
autoHideMilliseconds before it hides, or false to always show it1200
minThumbSizeThe smallest thumb, in pixels, for very long documents24
trackPressA click on the track moves a 'page' or 'jump's there'page'

It sets data-axis, data-state and data-dragging for your CSS; until you style the thumb, its color is --epdf-scrollbar-thumb. To build your own, such as a reading-progress bar, useScrollMetrics() gives you scrollTop, scrollHeight and clientHeight, the same numbers a scrolling element has.

Jump to an exact spot#

Give reveal() a rectangle and it becomes your “go to this search result” call:

stage.reveal(hit.page, {
  rect: hit.bounds, // a box in page coordinates
  zoom: 'fit-width', // zoom so the box spans the view, or 'keep'
  anchor: { y: 0.35 }, // about a third from the top, like a browser's find bar
});

Search results, comments and bookmarks all come down to this one call.

Loading live preview…

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

import './reveal.css';

const engine = localEngine();
const plugins = [stagePlugin(), 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()) };
};

// Spots to jump to: a page index and a box in page coordinates (points from
// the page's top-left), like a search hit or a comment would give you.
const SPOTS = [
  { label: 'Top of page 2', page: 1, rect: { x: 72, y: 72, width: 468, height: 96 } },
  { label: 'Middle of page 3', page: 2, rect: { x: 72, y: 340, width: 468, height: 120 } },
  { label: 'Corner of page 4', page: 3, rect: { x: 330, y: 620, width: 210, height: 110 } },
];

const ZOOMS: { label: string; zoom: RevealZoom }[] = [
  { label: 'Keep zoom', zoom: 'keep' },
  { label: 'Fit width', zoom: 'fit-width' },
  { label: 'Fit the box', zoom: 'fit' },
];

export default function App() {
  const [active, setActive] = useState(0);
  const [zoom, setZoom] = useState<RevealZoom>('fit-width');

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <SpotPicker active={active} zoom={zoom} onActive={setActive} onZoom={setZoom} />
        <Stage
          className="stage"
          pageChrome={(page) =>
            SPOTS.map((spot, index) => {
              if (spot.page !== page.pageIndex) return null;
              // Page coordinates become pixels only here, as the spot is drawn.
              const box = page.transform.pageToViewRect(spot.rect);
              return (
                <div
                  key={spot.label}
                  className="spot"
                  data-active={index === active}
                  style={{ left: box.x, top: box.y, width: box.width, height: box.height }}
                />
              );
            })
          }
        >
          {() => <RenderLayer />}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

function SpotPicker({
  active,
  zoom,
  onActive,
  onZoom,
}: {
  active: number;
  zoom: RevealZoom;
  onActive: (index: number) => void;
  onZoom: (zoom: RevealZoom) => void;
}) {
  const stage = useStage();
  const pageCount = useStageState((state) => state.pageCount);

  const reveal = (index: number, revealZoom: RevealZoom) => {
    const spot = SPOTS[index];
    // The box lands about a third from the top, like a browser's find bar.
    stage.reveal(spot.page, { rect: spot.rect, zoom: revealZoom, anchor: { y: 0.35 } });
    onActive(index);
  };

  // Open on the first spot.
  useEffect(() => {
    stage.reveal(SPOTS[0].page, { rect: SPOTS[0].rect, zoom: 'fit-width', anchor: { y: 0.35 } });
  }, [stage]);

  return (
    <div className="toolbar">
      <div className="spots">
        {SPOTS.map((spot, index) => (
          <button
            key={spot.label}
            type="button"
            className="button"
            aria-pressed={index === active}
            disabled={spot.page >= pageCount}
            onClick={() => reveal(index, zoom)}
          >
            {spot.label}
          </button>
        ))}
      </div>
      <div className="segmented" role="group" aria-label="Zoom">
        {ZOOMS.map((option) => (
          <button
            key={option.label}
            type="button"
            aria-pressed={option.zoom === zoom}
            onClick={() => onZoom(option.zoom)}
          >
            {option.label}
          </button>
        ))}
      </div>
    </div>
  );
}

Follow a destination#

A destination is where a link, a bookmark or the document’s opening view points: a page, a spot on it, and a zoom. goToDestination() goes exactly there:

stage.goToDestination(destination);
 
// Page 5, with the point (72, 100) at the top-left of the view, at 200%
stage.goToDestination({ kind: 'xyz', page: pages[4].ref, x: 72, y: 100, zoom: 2 });
kindGoes to
'xyz'The point x, y at the top-left, at zoom
'fit'The whole page
'fitH'The page’s full width, with y at the top
'fitV'The page’s full height, with x at the left
'fitR'The box x, y, width, height, filling the view
'fitB', 'fitBH', 'fitBV'The same as fit, fitH and fitV

Any of x, y and zoom can be null or left out: the view keeps what it has on that axis.

Loading live preview…

import { useEffect } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput, PageDestination, PageInfo } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage, useStageState } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './destination.css';

const engine = localEngine();
const plugins = [stagePlugin(), 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()) };
};

// Destinations as a link, a bookmark or the document's opening view give them.
const destinationsFor = (pages: readonly PageInfo[]) => {
  const pageAt = (index: number) => (pages[index] ?? pages[pages.length - 1]).ref;
  return [
    {
      label: 'xyz: page 3 at (72, 100), 200%',
      destination: { kind: 'xyz', page: pageAt(2), x: 72, y: 100, zoom: 2 },
    },
    { label: 'fit: all of page 2', destination: { kind: 'fit', page: pageAt(1) } },
    { label: 'fitH: page 1 from y = 300', destination: { kind: 'fitH', page: pageAt(0), y: 300 } },
    {
      label: 'fitR: a box on page 4',
      destination: { kind: 'fitR', page: pageAt(3), x: 72, y: 420, width: 260, height: 160 },
    },
  ] satisfies { label: string; destination: PageDestination }[];
};

function Destinations() {
  const stage = useStage();
  const pages = usePageList();
  const { zoomLevel, currentPageIndex } = useStageState();
  const destinations = destinationsFor(pages);

  // Open where the first destination points.
  useEffect(() => {
    if (pages.length > 0) stage.goToDestination(destinationsFor(pages)[0].destination);
  }, [stage, pages]);

  return (
    <div className="toolbar">
      {destinations.map(({ label, destination }) => (
        <button
          key={label}
          type="button"
          className="button"
          onClick={() => stage.goToDestination(destination)}
        >
          {label}
        </button>
      ))}
      <output className="badge">
        page <strong>{currentPageIndex + 1}</strong> ·{' '}
        <strong>{Math.round(zoomLevel * 100)}%</strong>
      </output>
    </div>
  );
}

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

Make it feel right#

When someone clicks “next page”, should the page land at the top of the view, or centered like a slide? Four settings answer questions like that. They all take 'start', 'center', 'end' or a fraction such as 0.35, per axis:

SettingThe question it answersDefault
arrivalAlignWhere does a page land when you go to it?the top
zoomAlignWhat stays put when you zoom with a button?the center
anchorAlignWhat stays put when the window resizes or the layout changes?the top
fitAlignWhere does the content rest when everything fits?the center

The defaults feel like reading a document. Set them all to center for a presentation, where each move keeps the page centered like a slide:

Loading live preview…

import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage, useStageSettings } from '@embedpdf/react/stage';
import type { StageSettings } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './presets.css';

const engine = localEngine();
// Zoomed in, so a page is wider than the view and you can see where it lands.
const plugins = [stagePlugin({ zoom: { level: 1.6 } }), renderPlugin()];

// A preset is an object you keep, and apply with updateSettings().
const READING: Partial<StageSettings> = {
  arrivalAlign: { x: 'start', y: 'start' },
  zoomAlign: { x: 'center', y: 'center' },
  anchorAlign: { x: 'start', y: 'start' },
};
const PRESENTATION: Partial<StageSettings> = {
  arrivalAlign: { x: 'center', y: 'center' },
  zoomAlign: { x: 'center', y: 'center' },
  anchorAlign: { x: 'center', y: 'center' },
};

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 FeelSwitcher() {
  const stage = useStage();
  // The settings say which feel is on: pages land centered in a presentation.
  const presentation = useStageSettings((settings) => settings.arrivalAlign.y === 'center');

  return (
    <div className="toolbar">
      <div className="segmented" role="group" aria-label="Feel">
        <button
          type="button"
          aria-pressed={!presentation}
          onClick={() => stage.updateSettings(READING)}
        >
          Reading
        </button>
        <button
          type="button"
          aria-pressed={presentation}
          onClick={() => stage.updateSettings(PRESENTATION)}
        >
          Presentation
        </button>
      </div>
      <div className="pager">
        <button type="button" className="button" onClick={() => stage.previousPage()}>
          ‹ Previous
        </button>
        <button type="button" className="button" onClick={() => stage.nextPage()}>
          Next ›
        </button>
      </div>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <FeelSwitcher />
        <Stage className="stage">{() => <RenderLayer />}</Stage>
      </DocumentGate>
    </Viewer>
  );
}
const presentation = {
  arrivalAlign: { x: 'center', y: 'center' },
  zoomAlign: { x: 'center', y: 'center' },
  anchorAlign: { x: 'center', y: 'center' },
};
 
stage.updateSettings(presentation); // one change, and you keep your place

One call can override the setting just for itself: stage.goToPage(12, { arrivalAlign: { y: 'center' } }). 'keep' means “don’t move this axis”: with arrivalAlign: { x: 'keep', y: 'start' }, someone reading the left column of a two-column paper stays in that column as they page on.

Rotate the view#

For a scan that came out sideways, turn how the pages are shown without changing the file:

Loading live preview…

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

import './rotate-view.css';

const engine = localEngine();
// The pages start a quarter turn round, like a scan that came out sideways.
const plugins = [stagePlugin({ viewRotation: 90 }), 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 RotateButtons() {
  const stage = useStage();
  const viewRotation = useStageState((state) => state.viewRotation);

  return (
    <div className="toolbar">
      <button type="button" className="button" onClick={() => stage.rotateViewBy(-90)}>
        ⟲ Rotate left
      </button>
      <output className="readout">{viewRotation}°</output>
      <button type="button" className="button" onClick={() => stage.rotateViewBy(90)}>
        ⟳ Rotate right
      </button>
      <button
        type="button"
        className="button"
        disabled={viewRotation === 0}
        onClick={() => stage.setViewRotation(0)}
      >
        Upright
      </button>
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <RotateButtons />
        <Stage className="stage">{() => <RenderLayer />}</Stage>
      </DocumentGate>
    </Viewer>
  );
}
stage.rotateViewBy(90); // a quarter turn clockwise from here
stage.setViewRotation(180); // or an exact rotation

To rotate pages in the PDF itself, use Organizing pages. That’s an edit to the document, not a view setting, so keep the two on different buttons.

Remember where the user was#

// One page: remember the spot before leaving, go back to it later
const spot = stage.getViewpoint();
stage.goToPage(2, { viewpoint: spot });
 
// Everything: settings and position, as one object you can store
localStorage.setItem('view', JSON.stringify(stage.getViewState()));
stage.applyViewState(JSON.parse(localStorage.getItem('view')!));

A viewpoint remembers what you were looking at, not pixel offsets, so it restores correctly in a window of another size.

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput, PageRef } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage, useStageState } from '@embedpdf/react/stage';
import type { StageViewState, Viewpoint } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './viewpoint.css';

const engine = localEngine();
const plugins = [stagePlugin(), 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 Memory() {
  const stage = useStage();
  const { currentPageIndex, zoomLevel } = useStageState();
  // One spot on one page, and the whole view: settings and position.
  const [spot, setSpot] = useState<{ page: PageRef; viewpoint: Viewpoint; label: string } | null>(
    null,
  );
  const [view, setView] = useState<StageViewState | null>(null);

  const rememberSpot = () => {
    const page = stage.getCurrentPage();
    if (!page) return;
    const label = `page ${stage.getCurrentPageIndex() + 1}`;
    setSpot({ page, viewpoint: stage.getViewpoint(), label });
  };

  // Remember where the reader starts, so "Go back" works right away.
  useEffect(rememberSpot, [stage]);

  return (
    <div className="toolbar">
      <div className="group">
        <button type="button" className="button" onClick={rememberSpot}>
          Remember this spot
        </button>
        <button
          type="button"
          className="button"
          disabled={!spot}
          onClick={() => spot && stage.goToPage(spot.page, { viewpoint: spot.viewpoint })}
        >
          Go back{spot ? ` to ${spot.label}` : ''}
        </button>
      </div>
      <div className="group">
        <button type="button" className="button" onClick={() => setView(stage.getViewState())}>
          Save the view
        </button>
        <button
          type="button"
          className="button"
          disabled={!view}
          onClick={() => view && stage.applyViewState(view)}
        >
          Restore it
        </button>
      </div>
      <output className="badge">
        page <strong>{currentPageIndex + 1}</strong> ·{' '}
        <strong>{Math.round(zoomLevel * 100)}%</strong>
      </output>
    </div>
  );
}

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

Try it: scroll and zoom somewhere else, then press Go back to page 1.

Two views of one document#

Register the Stage twice, with its own id and token, for two independent views of the same document, such as a thumbnail strip next to the main view:

thumbs-token.ts
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(), // the main view
  stagePlugin({ id: 'stage-thumbs', token: ThumbsToken, layout: 'grid', zoom: { pageWidth: 120 } }),
];

Pass the token to the component and the hooks: <Stage token={ThumbsToken}> and useStage(ThumbsToken); the state and settings take it after the selector, useStageState(select, ThumbsToken). Each view has its own zoom and layout; both show the same live document.

Loading live preview…

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 './two-views.css';

const engine = localEngine();

// A second view of the same document: its own id and token, its own zoom and layout.
const OverviewToken = createCapabilityToken<StageCapability>('stage-overview');

const plugins = [
  stagePlugin(), // the main view
  stagePlugin({
    id: 'stage-overview',
    token: OverviewToken,
    layout: 'grid',
    columns: 'auto',
    zoom: { pageWidth: 72 },
    gap: { px: 10 },
    padding: 10,
    interaction: false, // a drag only scrolls it
    // A wide, short box (a phone) lines the pages up in one 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 ZoomBar({ label, token }: { label: string; token?: typeof OverviewToken }) {
  const stage = useStage(token);
  const zoomLevel = useStageState((state) => state.zoomLevel, token);

  return (
    <div className="zoom-bar">
      <span className="caption">{label}</span>
      <button
        type="button"
        className="button"
        aria-label="Zoom out"
        onClick={() => stage.zoomOut()}
      >
        −
      </button>
      <output className="readout">{Math.round(zoomLevel * 100)}%</output>
      <button type="button" className="button" aria-label="Zoom in" onClick={() => stage.zoomIn()}>
        +
      </button>
    </div>
  );
}

function Views() {
  const main = useStage();
  const current = useStageState((state) => state.currentPageIndex);

  return (
    <div className="views">
      <section className="view overview">
        <ZoomBar label="Overview" token={OverviewToken} />
        <Stage token={OverviewToken} className="stage">
          {(page) => (
            <button
              type="button"
              className="page-button"
              aria-label={`Go to page ${page.pageIndex + 1}`}
              aria-current={page.pageIndex === current}
              onClick={() => main.goToPage(page.ref)}
            >
              <RenderLayer />
            </button>
          )}
        </Stage>
      </section>
      <section className="view main">
        <ZoomBar label="Main view" />
        <Stage className="stage">{() => <RenderLayer />}</Stage>
      </section>
    </div>
  );
}

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

A thumbnail strip should usually not start text selection or drawing when someone drags it. Turn off its interaction setting; it keeps its own drag-to-scroll:

stagePlugin({
  id: 'stage-thumbs',
  token: ThumbsToken,
  layout: 'grid',
  interaction: false, // a drag doesn't select text or draw
});

Methods#

Everything useStage() gives you.

MethodWhat it does
zoomIn(), zoomOut()Zooms one step in or out, around the zoomAlign point. Zoom
fitWidth(), fitPage(), fitAll(), fitAutomatic()Switches to a fit mode, which keeps applying when the window resizes
zoomTo(zoom, { around? })Zooms to a level (1.5), a mode ({ mode: 'fit-width' }) or a page size ({ pageWidth }, { pageHeight }). around is a point in the Stage that stays put.
zoomBy(factor, { around? })Multiplies the zoom: 1.25 is 25% closer
goToPage(page, options?)Goes to a page, by its ref or its index. Move through pages
nextPage(options?), previousPage(options?)Goes to the next or previous page, or a whole spread when two facing pages fit
goToFirstPage(options?), goToLastPage(options?)Goes to the first or the last page
canGoNext(), canGoPrevious()Whether there’s a page after or before the current one, to disable your buttons
reveal(page, options?)Brings a page into view, scrolling only as far as needed. With rect, zoom or anchor, it lands on that spot. Jump to an exact spot
goToDestination(destination)Goes where a link, a bookmark or the document’s opening view points. Follow a destination
scrollTo({ left, top }), scrollBy({ left, top })Scrolls like a scrolling element does, in screen pixels. It jumps unless you pass behavior: 'smooth'. Scrollbars
panBy(x, y)Moves the view by x and y screen pixels
resetView()Jumps back to the first page and keeps the zoom mode
isMoving(), stopMotion()Whether a glide or a fling is running, and stopping it where it is
getCamera(), setCamera(camera)Where the view is, as { x, y, zoom }, and moving it there at once. Every scroll frame
setViewRotation(rotation), rotateViewBy(degrees)Turns how the pages are shown: to 0, 90, 180 or 270, or a quarter turn from here with 90 or -90. Rotate the view
getViewpoint()What the user is looking at on the current page, with the zoom, to pass back to goToPage(page, { viewpoint }). Remember where the user was
getViewState(), applyViewState(view)Every setting and the position as one object you can store, and restoring it. The responsive rules apply again on top.
getSettings(), updateSettings(changes), resetSettings()Reads, changes and resets the live settings, keeping you on the same page. Settings
setResponsiveRules(rules)Replaces the responsive rules while the app runs. Adapt to the space you have
listVisiblePages(), isPageVisible(page)The pages on screen, each with its box in the Stage and the part of it that shows, and whether one page is on screen
listCurrentItemPages()The pages of the current spread, or the current page alone
getPageAt(point)The page under a point in the Stage and the point on that page, or null over the space between pages
viewportToPage(page, point), pageToViewport(page, point), pageRectToViewport(page, rect)Converts a point between the Stage and a page, and a box on a page to the Stage. null when the page isn’t laid out. Page coordinates
getPageFrame(page)Where a page is laid out in the Stage, on screen or not, or null before it’s placed
getViewportSize()The Stage’s size in screen pixels, as last measured

The page moves (goToPage(), nextPage(), previousPage(), goToFirstPage(), goToLastPage()) take { behavior, arrivalAlign, viewpoint }: behavior is the scrollBehavior setting for one call, arrivalAlign is where the page lands, and viewpoint is a saved spot to go back to. reveal() and goToDestination() take behavior too.

State#

Everything useStageState() gives you. Your component re-renders when a value changes. The method in the last column reads the same value once.

FieldWhat it isRead once
zoomLevelThe zoom as a number: 1 is 100%getZoomLevel()
zoomModeThe fit mode, such as 'fit-width', or 'custom' after a pinch or an exact levelgetZoomMode()
currentPageIndexThe current page’s place in the document, from 0getCurrentPageIndex()
currentPageThe current page’s ref, or null before the document has pagesgetCurrentPage()
pageCountHow many pages the document hasgetPageCount()
viewRotationHow the view is turned: 0, 90, 180 or 270getViewRotation()
activeRulesThe names of the responsive rules that apply now, in orderlistActiveRules(), or matchesRule(name) for one

The settings come from useStageSettings(), and the document’s pages from usePageList().

Settings#

Set them when you register the plugin, and change them while the app runs with stage.updateSettings({ … }). stage.resetSettings() goes back to what you registered, and useStageSettings() gives you the current settings.

SettingWhat it doesDefault
flowScroll every page, or one page or 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' or a number'square'
zoomA mode, a level, { pageWidth } or { pageHeight }{ mode: 'automatic' }
paddingSpace around the pages, in screen pixels24 (4 when compact)
gapSpace between pages: a number (grows with zoom) or { px }16
pageFrameSpace reserved around each page for your labels, in screen pixels0 on every side
boundedKeep panning within the pages; false for an endless canvastrue
directionReading direction: 'rtl' flips the page order and spreads'ltr'
arrivalAlignWhere a page lands when you go to it{ x: 'start', y: 'start' }
zoomAlignWhat stays put when you zoom with a button{ x: 'center', y: 'center' }
anchorAlignWhat stays put when the window resizes or the layout changes{ x: 'start', y: 'start' }
fitAlignWhere the content rests when everything fits{ x: 'center', y: 'center' }
viewRotationHow the pages are turned: 0, 90, 180, 2700
scrollBehaviorWhether going to a page glides or jumps: 'smooth', 'instant''smooth'
interactionDrags reach the active tool, such as text selection; false for a view that only scrollstrue
panFallbackA drag in the space between pages scrolls, whatever the tooltrue
zoomGesturesA pinch or Ctrl+wheel zooms; false keeps the zoom fixedtrue
viewUnitsPerPointScreen pixels per PDF point at 100%, so 100% is the page’s real size; a native app sets its own96 / 72 on the web

Set once, when you register the plugin:

SettingWhat it doesDefault
responsiveRules that change settings by the Stage’s sizeone compact rule
id, tokenFor a second view of the same documentthe main view

Events#

page-analytics.tsx
import { RenderLayer } from '@embedpdf/react/render';
import { Stage, useStageEvent } from '@embedpdf/react/stage';
import { analytics } from './analytics';

export function Reader() {
  useStageEvent(
    (stage) => stage.onPageChanged,
    ({ pageIndex }) => analytics.track('page_view', { page: pageIndex + 1 }),
  );

  return <Stage>{() => <RenderLayer />}</Stage>;
}

Loading live preview…

import { useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage, useStageEvent } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { localEngine } from '@embedpdf/engine';

import './events.css';

const engine = localEngine();
const plugins = [stagePlugin(), 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()) };
};

interface Entry {
  id: number;
  event: string;
  detail: string;
}

// The latest stage events, newest first. Scroll, zoom or resize to add more.
function EventLog() {
  const stage = useStage();
  const [entries, setEntries] = useState<Entry[]>([]);
  const count = useRef(0);
  const log = (event: string, detail: string) =>
    setEntries((current) => [{ id: count.current++, event, detail }, ...current].slice(0, 6));

  useStageEvent(
    (stage) => stage.onPageChanged,
    ({ pageIndex, previousPageIndex }) =>
      log('onPageChanged', `page ${previousPageIndex + 1} → ${pageIndex + 1}`),
  );
  useStageEvent(
    (stage) => stage.onZoomChanged,
    ({ level, mode }) => log('onZoomChanged', `${Math.round(level * 100)}%, ${mode}`),
  );
  useStageEvent(
    (stage) => stage.onMotionEnded,
    ({ camera }) => log('onMotionEnded', `at rest, ${Math.round(camera.zoom * 100)}%`),
  );
  useStageEvent(
    (stage) => stage.onViewportChanged,
    ({ size }) =>
      log('onViewportChanged', `${Math.round(size.width)} × ${Math.round(size.height)}`),
  );

  // Glide to the second page on load, so the log has something to show.
  useEffect(() => stage.goToPage(1), [stage]);

  return (
    <ol className="log" aria-live="polite">
      {entries.map((entry) => (
        <li key={entry.id} className="entry">
          <code className="name">{entry.event}</code>
          <span className="detail">{entry.detail}</span>
        </li>
      ))}
    </ol>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <div className="layout">
          <EventLog />
          <Stage className="stage">{() => <RenderLayer />}</Stage>
        </div>
      </DocumentGate>
    </Viewer>
  );
}
EventFires whenCarries
onPageChangedAnother page becomes the current onepage, pageIndex, previousPageIndex
onZoomChangedThe zoom level changedlevel, previousLevel, mode
onCameraChangedThe view moved, on every frame of a scroll or zoomcamera
onMotionEndedScrolling, a fling or an animated move came to restcamera
onViewportChangedThe Stage itself changed sizesize
onSettingsChangedA setting changed, by you or by a responsive rulesettings, changed

Good to know#

What “current page” means#

The current page is the one that fills most of the view, and with spreads, the first of the spread. It changes as you scroll, before the scroll comes to rest.

Every scroll frame#

onCameraChanged fires on every frame of a scroll or zoom. Use it for things that must track the view exactly, and onMotionEnded for anything expensive.

Page coordinates#

Positions you pass to reveal() and goToDestination() are in points from the top-left of the page, like every position in EmbedPDF. A destination from another PDF tool uses the file’s own numbers; turn it into this form with pageDestinationOf(destination, page.pdfCropBox) from the engine. See Coordinates.

How pages stay sharp#

The Stage lays the pages out; the render plugin draws them, at every zoom, within the memory budget you set.

Was this page helpful?

Your feedback goes directly to the documentation team.