EmbedPDF

Menus over the page

A menu next to selected text, a card next to an annotation, a button over a search match: UI that belongs to a spot on the page, but floats above it. <Anchored> places your UI at a spot, and keeps it there while people scroll, zoom and rotate. Put it in the Stage’s overlay:

stage-overlay.tsx
import { Anchored } from '@embedpdf/react/anchored';
import { RenderLayer } from '@embedpdf/react/render';
import type { SearchHit } from '@embedpdf/react/search';
import { Stage } from '@embedpdf/react/stage';
import { highlight } from './highlight';

export function Pages({ hit }: { hit: SearchHit }) {
  return (
    <Stage
      overlay={
        <Anchored anchor={{ page: hit.page, bounds: hit.bounds }} placement="bottom">
          <button onClick={() => highlight(hit)}>Highlight</button>
        </Anchored>
      }
    >
      {() => <RenderLayer />}
    </Stage>
  );
}

The anchor is a box on a page, in page coordinates: the same page and bounds every plugin gives you. The overlay moves with the view in the same frame as the pages, so your menu never lags behind.

Loading live preview…

import { useEffect } 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, useSearchState } from '@embedpdf/react/search';
import { Anchored } from '@embedpdf/react/anchored';
import { localEngine } from '@embedpdf/engine';

import './overlay.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', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// A button below the active match: it follows the match while you scroll and zoom.
function NextMatch() {
  const search = useSearch();
  const { activeHit, activeHitIndex, hitCount } = useSearchState();

  return (
    <Anchored
      anchor={activeHit && { page: activeHit.page, bounds: activeHit.bounds }}
      placement="bottom"
    >
      <button type="button" className="pill" onClick={() => search.nextHit()}>
        {activeHitIndex + 1} of {hitCount} · Next →
      </button>
    </Anchored>
  );
}

function Pages() {
  const search = useSearch();

  // Every "PDF" in the document, and the view on the first one.
  useEffect(() => {
    void search.search({ text: 'PDF' }).then(() => search.revealActiveHit());
  }, [search]);

  return (
    <Stage className="stage" overlay={<NextMatch />}>
      {() => (
        <>
          <RenderLayer />
          <SearchLayer />
        </>
      )}
    </Stage>
  );
}

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

Ready-made menus#

Most menus follow something that’s already on the page. These place themselves:

ComponentAttaches toPage
<SelectionMenu>The selected textSelecting & copying
<AnnotationMenu>The selected annotationsSelecting & editing
<AnnotationDraftMenu>A polygon or polyline being drawnSelecting & editing

They hide while people drag, and show when the selection settles. What’s inside is yours.

Attach to an annotation#

useAnnotationAnchor(ref) gives you an annotation’s anchor, and follows it while it’s moved. A count of replies next to a note stays put, so it’s pinned:

reply-count.tsx
import { Anchored } from '@embedpdf/react/anchored';
import { type Annotation, useAnnotationAnchor, useCommentThread } from '@embedpdf/react/annotation';

export function ReplyCount({ note }: { note: Annotation }) {
  const anchor = useAnnotationAnchor(note.ref);
  const thread = useCommentThread(note.ref);

  return (
    <Anchored anchor={anchor} placement="right" gap={4} pinned>
      <span className="badge">{thread?.replies.length ?? 0}</span>
    </Anchored>
  );
}

Where it goes#

PropWhat it isDefault
anchor{ page, bounds }, or null to hide. avoid adds points to keep clear of, such as a rotation handlerequired
placement'top', 'bottom', 'left' or 'right' of the box, centred on that side, or lined up with its start or end: 'top-start', 'top-end', 'right-start' and so on'top'
gapThe space between the box and your UI, in pixels. Negative overlaps the box, for a badge on a corner8
pinnedStays exactly where placement puts it: no flipping, no moving to stay in viewfalse

When there’s no room on the side you chose, it flips to the other side, and it stays inside the view. That’s right for a menu. A badge or a status belongs on its spot, so pin it: it stays there and scrolls away with its box. Scroll the match close to an edge, then turn on Pinned:

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, useSearchState } from '@embedpdf/react/search';
import { Anchored } from '@embedpdf/react/anchored';
import type { AnchoredPlacement, AnchoredSide } from '@embedpdf/react/anchored';
import { localEngine } from '@embedpdf/engine';

import './placement.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', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

const SIDES: AnchoredSide[] = ['top', 'right', 'bottom', 'left'];
const ALIGNS = ['start', 'center', 'end'] as const;

// A card next to the active match, where you put it, `gap` pixels away.
function MatchCard(props: { placement: AnchoredPlacement; gap: number; pinned: boolean }) {
  const search = useSearch();
  const { activeHit, activeHitIndex, hitCount } = useSearchState();

  return (
    <Anchored
      anchor={activeHit && { page: activeHit.page, bounds: activeHit.bounds }}
      placement={props.placement}
      gap={props.gap}
      pinned={props.pinned}
    >
      <div className="card">
        <span className="card-title">
          Match {activeHitIndex + 1} of {hitCount}
        </span>
        <span className="card-page">Page {(activeHit?.pageIndex ?? 0) + 1}</span>
        <div className="card-steps">
          <button
            type="button"
            className="step"
            aria-label="Previous match"
            onClick={() => search.previousHit()}
          >
            ←
          </button>
          <button
            type="button"
            className="step"
            aria-label="Next match"
            onClick={() => search.nextHit()}
          >
            →
          </button>
        </div>
      </div>
    </Anchored>
  );
}

function Pages() {
  const search = useSearch();
  const [side, setSide] = useState<AnchoredSide>('top');
  const [align, setAlign] = useState<(typeof ALIGNS)[number]>('center');
  const [gap, setGap] = useState(8);
  const [pinned, setPinned] = useState(false);
  const placement: AnchoredPlacement = align === 'center' ? side : `${side}-${align}`;

  useEffect(() => {
    void search.search({ text: 'PDF' }).then(() => search.revealActiveHit());
  }, [search]);

  return (
    <>
      <div className="toolbar">
        <div className="segmented" role="radiogroup" aria-label="Side">
          {SIDES.map((each) => (
            <button
              key={each}
              type="button"
              role="radio"
              aria-checked={each === side}
              className="segment"
              onClick={() => setSide(each)}
            >
              {each}
            </button>
          ))}
        </div>
        <div className="segmented" role="radiogroup" aria-label="Along the side">
          {ALIGNS.map((each) => (
            <button
              key={each}
              type="button"
              role="radio"
              aria-checked={each === align}
              className="segment"
              onClick={() => setAlign(each)}
            >
              {each}
            </button>
          ))}
        </div>
        <label className="range">
          Gap {gap} px
          <input
            type="range"
            min={-24}
            max={32}
            value={gap}
            onChange={(event) => setGap(Number(event.target.value))}
          />
        </label>
        <label className="switch">
          <input
            type="checkbox"
            checked={pinned}
            onChange={(event) => setPinned(event.target.checked)}
          />
          Pinned
        </label>
      </div>
      <Stage
        className="stage"
        overlay={<MatchCard placement={placement} gap={gap} pinned={pinned} />}
      >
        {() => (
          <>
            <RenderLayer />
            <SearchLayer />
          </>
        )}
      </Stage>
    </>
  );
}

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

Good to know#

Only what’s on screen costs anything#

Anchored UI on a page that isn’t on screen isn’t drawn, and doesn’t follow the scroll or the zoom. Mount one for every annotation that needs it; a document with hundreds costs only the ones in view.

Clicks stay in your menu#

A click in an anchored menu never reaches the page underneath, so it doesn’t clear the selection or start a drag.

In a PageView#

Inside a PageView, anchored UI is placed above everything else on your page, so a scrolling list or a card can’t cut it off.

Was this page helpful?

Your feedback goes directly to the documentation team.