EmbedPDF

How it works

A headless viewer is a few parts that always work the same way. Learn them once here, and every plugin page reads the same: the same four hooks, the same kinds of settings and events, the same checks.

The viewer and its documents#

<Viewer> holds the engine, the plugins and the open documents. Everything inside it can use them:

viewer.tsx
import { DocumentGate, Viewer } from '@embedpdf/react/runtime';
import { RenderLayer } from '@embedpdf/react/render';
import { Stage } from '@embedpdf/react/stage';
import { engine, plugins, source } from './pdf';
import { Toolbar } from './toolbar';

export function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source }]}>
      <Toolbar />
      <DocumentGate fallback={<p>Opening…</p>}>
        <Stage>{() => <RenderLayer />}</Stage>
      </DocumentGate>
    </Viewer>
  );
}

A viewer can have several documents open, like tabs, and one of them is active. A component talks to the active document unless you put it inside <DocumentScope>, which is how a tab strip or a side-by-side view gives each part its own document:

document-scope.tsx
import { DocumentScope } from '@embedpdf/react/runtime';
import { RenderLayer } from '@embedpdf/react/render';
import { Stage } from '@embedpdf/react/stage';

export function DocumentPane({ documentId }: { documentId: string }) {
  return (
    <DocumentScope id={documentId}>
      <Stage>{() => <RenderLayer />}</Stage>
    </DocumentScope>
  );
}

<DocumentGate> renders its children only once the document is open, so the components inside can count on it. See Opening documents.

Plugins#

A plugin adds one feature. You register it when you create the viewer, with its settings:

plugins.ts
import { renderPlugin } from '@embedpdf/react/render';
import { searchPlugin } from '@embedpdf/react/search';
import { stagePlugin } from '@embedpdf/react/stage';

export const plugins = [stagePlugin({ layout: 'horizontal' }), renderPlugin(), searchPlugin()];

Most plugins keep their state per document: each open document has its own search, its own selection, its own zoom. A few belong to the whole viewer, such as translations, commands and stamp libraries.

What a plugin gives you#

Every plugin gives your components four hooks, each with one job:

HookGives youYour component re-renders
useSearch()the plugin’s API: everything it can donever
useSearchState()the data to show, such as the number of hitswhen that data changes
useSearchEvent()a call to your function when something happensnever
useSearchSettings()the plugin’s current settingswhen a setting changes

Search is the example; every plugin follows the pattern: useStage() and useStageState(), useAnnotation() and useAnnotationEvent(), and so on. A plugin without settings has no settings hook, and one without events has no event hook.

search-box.tsx
import { useSearch, useSearchState } from '@embedpdf/react/search';

export function SearchBox() {
  const search = useSearch();
  const { hitCount, activeHitIndex } = useSearchState();

  return (
    <>
      <input onChange={(event) => search.search({ text: event.target.value })} />
      <span>
        {activeHitIndex + 1} of {hitCount}
      </span>
      <button onClick={() => search.nextHit()}>Next</button>
    </>
  );
}
  • Do things with the API hook. Its object never changes, so it’s safe in effects and callbacks.
  • Show things with the state hook. To re-render only when one value changes, pass a function that picks it: useSearchState((state) => state.hitCount).
  • React to things with the event hook. It listens while the component is mounted:
match-announcer.tsx
import { useSearchEvent } from '@embedpdf/react/search';
import { announce } from './announce';

export function MatchAnnouncer() {
  useSearchEvent(
    (search) => search.onCompleted,
    ({ hitCount }) => announce(`${hitCount} matches`),
  );

  return null;
}

All four together: the API searches, the state shows the count, the event announces each finished search, and the settings change the highlight color.

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 } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  SearchLayer,
  searchPlugin,
  useSearch,
  useSearchEvent,
  useSearchSettings,
  useSearchState,
} from '@embedpdf/react/search';
import { localEngine } from '@embedpdf/engine';

import './hooks.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', bytes: new Uint8Array(await response.arrayBuffer()) };
};

const colors = ['#ffd500', '#7dd3fc', '#86efac'];

function SearchBox() {
  // The API: what search can do.
  const search = useSearch();
  // The data to show: re-renders when the count or the active match changes.
  const { hitCount, activeHitIndex } = useSearchState();
  // The settings: re-renders when the highlight color changes.
  const color = useSearchSettings((settings) => settings.highlight.color);
  const [text, setText] = useState('PDF');
  const [announcement, setAnnouncement] = useState('');

  // A call to your function when something happens.
  useSearchEvent(
    (search) => search.onCompleted,
    ({ hitCount }) => setAnnouncement(`The search finished with ${hitCount} matches.`),
  );

  useEffect(() => {
    void search.search({ text });
  }, [search, text]);

  return (
    <>
      <div className="toolbar">
        <input
          className="field"
          type="search"
          aria-label="Search"
          value={text}
          onChange={(event) => setText(event.target.value)}
        />
        <output className="readout">
          {hitCount > 0 ? `${activeHitIndex + 1} of ${hitCount}` : 'No matches'}
        </output>
        <button type="button" className="button" onClick={() => search.nextHit()}>
          Next
        </button>
        {colors.map((swatch) => (
          <button
            key={swatch}
            type="button"
            className="swatch"
            aria-label={`Highlight in ${swatch}`}
            aria-pressed={color === swatch}
            style={{ background: swatch }}
            onClick={() => search.updateSettings({ highlight: { color: swatch } })}
          />
        ))}
        <button type="button" className="button" onClick={() => search.resetSettings()}>
          Reset
        </button>
      </div>
      <p className="announcement" aria-live="polite">
        {announcement}
      </p>
    </>
  );
}

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

A few hooks read one specific thing and are named after it, such as useAnnotationList(filter), usePageList() or useSurface(id).

Layers and overlays#

What you draw goes in one of two places.

Layers draw inside each page. The Stage calls its children once per visible page, and each layer draws for that page. Later layers sit on top:

layers.tsx
import { AnnotationLayer } from '@embedpdf/react/annotation';
import { RenderLayer } from '@embedpdf/react/render';
import { SearchLayer } from '@embedpdf/react/search';
import { SelectionLayer } from '@embedpdf/react/selection';
import { Stage } from '@embedpdf/react/stage';

export function Pages() {
  return (
    <Stage>
      {() => (
        <>
          <RenderLayer />
          <SearchLayer />
          <SelectionLayer />
          <AnnotationLayer />
        </>
      )}
    </Stage>
  );
}

A layer zooms, scrolls and rotates with its page, and you never convert a position: everything a plugin gives you is in page coordinates, and the layer places it. A page’s layers also keep out of each other’s way: while an annotation layer or a form layer is on the page, the render layer leaves what it draws out of the page picture, so nothing is drawn twice.

Overlays float above the pages, attached to something on them: a menu next to the selected text, a card next to an annotation. They go in the Stage’s overlay, which keeps them attached while the user scrolls and zooms. See Menus over the page.

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate, usePageList } 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 { interactionPlugin } from '@embedpdf/react/interaction';
import { SearchLayer, searchPlugin, useSearch } from '@embedpdf/react/search';
import {
  SelectionLayer,
  SelectionMenu,
  selectionPlugin,
  useSelection,
} from '@embedpdf/react/selection';
import { localEngine } from '@embedpdf/engine';

import './layers.css';

const engine = localEngine();
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  selectionPlugin(),
  searchPlugin(),
];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// Something on every layer on load: the matches of a search, and the title selected.
function OnLoad() {
  const search = useSearch();
  const selection = useSelection();
  const cover = usePageList()[0]?.ref;

  useEffect(() => {
    void search.search({ text: 'PDF' });
  }, [search]);
  useEffect(() => {
    if (cover) selection.select({ page: cover, start: 10, count: 52 });
  }, [selection, cover]);

  return null;
}

function Menu() {
  const selection = useSelection();
  return (
    <div className="menu">
      <button type="button" onClick={() => selection.clear()}>
        Clear the selection
      </button>
    </div>
  );
}

export default function App() {
  const [showSearch, setShowSearch] = useState(true);
  const [showSelection, setShowSelection] = useState(true);

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Opening…</p>}>
        <OnLoad />
        <div className="toolbar">
          <label className="option">
            <input
              type="checkbox"
              checked={showSearch}
              onChange={(event) => setShowSearch(event.target.checked)}
            />
            Search matches
          </label>
          <label className="option">
            <input
              type="checkbox"
              checked={showSelection}
              onChange={(event) => setShowSelection(event.target.checked)}
            />
            Text selection
          </label>
        </div>
        {/* Layers draw inside each page, later ones on top; the overlay floats above them. */}
        <Stage
          className="stage"
          overlay={
            showSelection && (
              <SelectionMenu>
                <Menu />
              </SelectionMenu>
            )
          }
        >
          {() => (
            <>
              <RenderLayer />
              {showSearch && <SearchLayer />}
              {showSelection && <SelectionLayer />}
            </>
          )}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

Settings#

A plugin’s settings are the options you pass when you register it, and each plugin page lists them under Settings. Every plugin lets you change them while the app runs, for example when the user picks another layout:

stage.updateSettings({ layout: 'grid' });
stage.resetSettings(); // back to what you registered

Every plugin with settings has the same three calls, a useStageSettings()-style hook, and an onSettingsChanged event.

Colors are settings too, such as the highlight of a search match or the handles of an annotation. Each color setting can also come from a CSS variable, which wins over the setting, for dark mode or a different look per viewer: see Colors & theming.

Checks before you act#

Anything the user may not be allowed to do has a check with the same name: annotation.canCreate(), search.canSearch(), pageEdit.canEdit(). Use them to hide or disable buttons. The engine checks every call again, so they keep your UI honest; they’re not the guard. See Permissions.

Good to know#

Code outside components#

onReady gives you the viewer once it has started, for code that isn’t a component: a keyboard handler, an analytics hook, a test.

outside-components.tsx
import { Viewer } from '@embedpdf/react/runtime';
import { SearchToken } from '@embedpdf/react/search';
import { engine, plugins } from './pdf';

export function App() {
  return (
    <Viewer
      engine={engine}
      plugins={plugins}
      onReady={(viewer) => {
        const search = viewer.capability(SearchToken);
      }}
    />
  );
}

Each plugin exports its token (SearchToken, StageToken, …). A ref on <Viewer> gives you the same object.

Plugins don’t touch the page#

Plugins hold state and logic, never HTML. The framework packages draw. That’s why every plugin works the same in React, Vue, Svelte and Angular, and why you can use a plugin’s API in a test without a browser.

Coordinates#

Every position is in points from the top-left corner of the page, with y going down. Layers and overlays convert to pixels for you. See Coordinates.

Was this page helpful?

Your feedback goes directly to the documentation team.