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 snippet:
<script lang="ts">
import { Anchored } from '@embedpdf/svelte/anchored';
import { RenderLayer } from '@embedpdf/svelte/render';
import type { SearchHit } from '@embedpdf/svelte/search';
import { Stage } from '@embedpdf/svelte/stage';
import { highlight } from './highlight';
let { hit }: { hit: SearchHit } = $props();
</script>
<Stage>
<RenderLayer />
{#snippet overlay()}
<Anchored anchor={{ page: hit.page, bounds: hit.bounds }} placement="bottom">
<button onclick={() => highlight(hit)}>Highlight</button>
</Anchored>
{/snippet}
</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…
<script lang="ts" module>
import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
import { renderPlugin } from '@embedpdf/svelte/render';
import { searchPlugin } from '@embedpdf/svelte/search';
import { stagePlugin } from '@embedpdf/svelte/stage';
import { localEngine } from '@embedpdf/engine';
import Pages from './Pages.svelte';
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()) };
};
</script>
<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate>
{#snippet fallback()}
<p class="loading">Loading…</p>
{/snippet}
<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:
<script lang="ts">
import { Anchored } from '@embedpdf/svelte/anchored';
import { type Annotation, useAnnotationAnchor, useCommentThread } from '@embedpdf/svelte/annotation';
let { note }: { note: Annotation } = $props();
const anchor = useAnnotationAnchor(() => note.ref);
const thread = useCommentThread(() => note.ref);
</script>
<Anchored anchor={anchor.current} placement="right" gap={4} pinned>
<span class="badge">{thread.current?.replies.length ?? 0}</span>
</Anchored>Pass the ref as a function, and the anchor and the thread follow note when it changes.
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…
<script lang="ts" module>
import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
import { renderPlugin } from '@embedpdf/svelte/render';
import { searchPlugin } from '@embedpdf/svelte/search';
import { stagePlugin } from '@embedpdf/svelte/stage';
import { localEngine } from '@embedpdf/engine';
import Pages from './Pages.svelte';
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()) };
};
</script>
<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate>
{#snippet fallback()}
<p class="loading">Loading…</p>
{/snippet}
<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.