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:
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:
| Component | Attaches to | Page |
|---|---|---|
<SelectionMenu> | The selected text | Selecting & copying |
<AnnotationMenu> | The selected annotations | Selecting & editing |
<AnnotationDraftMenu> | A polygon or polyline being drawn | Selecting & 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:
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#
| Prop | What it is | Default |
|---|---|---|
anchor | { page, bounds }, or null to hide. avoid adds points to keep clear of, such as a rotation handle | required |
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' |
gap | The space between the box and your UI, in pixels. Negative overlaps the box, for a badge on a corner | 8 |
pinned | Stays exactly where placement puts it: no flipping, no moving to stay in view | false |
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.
Your feedback goes directly to the documentation team.