---
title: "Menus over the page — Svelte"
description: "Attach your own UI to a spot on a page, such as a selection, an annotation or a search match, and keep it there while people scroll and zoom."
framework: "Svelte"
source: "https://www.embedpdf.com/docs/headless/svelte/ui/anchored"
---

# 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:

**`stage-overlay.svelte`**

```svelte
<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.

**`App.svelte`**

```svelte
<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>
```

**`overlay.css`**

```css
/* Colors */
:root {
  --muted: #5a6b92;
  --line: #e6eaf2;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-strong: #043f8f;
  --on-accent: #ffffff;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The button that follows the match */
.pill {
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  color: var(--on-accent);
  background: var(--accent);
  border: 0;
  border-radius: 999px;
  padding: 9px 14px;
  cursor: pointer;
  box-shadow: 0 8px 20px rgb(7 32 76 / 0.25);
  transition: background-color 0.15s ease;
}

.pill:hover {
  background: var(--accent-strong);
}

.pill:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

**`NextMatch.svelte`**

```svelte
<!-- A button below the active match: it follows the match while you scroll and zoom. -->
<script lang="ts">
  import { Anchored } from '@embedpdf/svelte/anchored';
  import { useSearch, useSearchState } from '@embedpdf/svelte/search';

  const search = useSearch();
  const state = useSearchState();
</script>

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

**`Pages.svelte`**

```svelte
<script lang="ts">
  import { RenderLayer } from '@embedpdf/svelte/render';
  import { SearchLayer, useSearch } from '@embedpdf/svelte/search';
  import { Stage } from '@embedpdf/svelte/stage';
  import NextMatch from './NextMatch.svelte';

  const search = useSearch();

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

<Stage class="stage">
  <RenderLayer />
  <SearchLayer />
  {#snippet overlay()}
    <NextMatch />
  {/snippet}
</Stage>
```

## 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](../text/selection#add-a-selection-menu)           |
| `<AnnotationMenu>`      | The selected annotations          | [Selecting & editing](../annotations/selecting#add-a-menu)              |
| `<AnnotationDraftMenu>` | A polygon or polyline being drawn | [Selecting & editing](../annotations/selecting#shapes-with-many-points) |

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.svelte`**

```svelte
<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**:

**`App.svelte`**

```svelte
<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>
```

**`placement.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-tint: #e7f0fe;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* Where the card goes, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 18px;
  margin-bottom: 14px;
}

.segmented {
  display: inline-flex;
  gap: 2px;
  padding: 3px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 11px;
  box-shadow: 0 1px 2px var(--shadow);
}

.segment {
  min-width: 58px;
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 8px 10px;
  cursor: pointer;
}

.segment:hover {
  color: var(--ink);
  background: var(--tint);
}

.segment[aria-checked='true'] {
  color: var(--accent);
  background: var(--accent-tint);
}

.range {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.switch {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-weight: 550;
  color: var(--ink);
  cursor: pointer;
}

.switch input {
  width: 16px;
  height: 16px;
  margin: 0;
  accent-color: var(--accent);
}

/* The card next to the match */
.card {
  display: grid;
  grid-template-columns: auto auto;
  align-items: center;
  gap: 2px 14px;
  padding: 10px 12px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 12px;
  box-shadow: 0 10px 28px rgb(7 32 76 / 0.2);
}

.card-title {
  font-size: 13px;
  font-weight: 650;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.card-page {
  grid-row: 2;
  font-size: 12px;
  color: var(--muted);
}

.card-steps {
  grid-row: 1 / span 2;
  grid-column: 2;
  display: flex;
  gap: 4px;
}

.step {
  width: 30px;
  height: 30px;
  font: inherit;
  font-size: 14px;
  color: var(--ink);
  background: var(--tint);
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

.step:hover {
  color: var(--accent);
  background: var(--accent-tint);
}

.segment:focus-visible,
.step:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

**`MatchCard.svelte`**

```svelte
<!-- A card next to the active match, where you put it, `gap` pixels away. -->
<script lang="ts">
  import { Anchored, type AnchoredPlacement } from '@embedpdf/svelte/anchored';
  import { useSearch, useSearchState } from '@embedpdf/svelte/search';

  let { placement, gap, pinned }: { placement: AnchoredPlacement; gap: number; pinned: boolean } =
    $props();

  const search = useSearch();
  const state = useSearchState();
</script>

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

**`Pages.svelte`**

```svelte
<script lang="ts">
  import type { AnchoredPlacement, AnchoredSide } from '@embedpdf/svelte/anchored';
  import { RenderLayer } from '@embedpdf/svelte/render';
  import { SearchLayer, useSearch } from '@embedpdf/svelte/search';
  import { Stage } from '@embedpdf/svelte/stage';
  import MatchCard from './MatchCard.svelte';

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

  const search = useSearch();
  let side = $state<AnchoredSide>('top');
  let align = $state<(typeof ALIGNS)[number]>('center');
  let gap = $state(8);
  let pinned = $state(false);
  const placement = $derived<AnchoredPlacement>(align === 'center' ? side : `${side}-${align}`);

  $effect(() => {
    void search.search({ text: 'PDF' }).then(() => search.revealActiveHit());
  });
</script>

<div class="toolbar">
  <div class="segmented" role="radiogroup" aria-label="Side">
    {#each SIDES as each (each)}
      <button
        type="button"
        role="radio"
        aria-checked={each === side}
        class="segment"
        onclick={() => (side = each)}
      >
        {each}
      </button>
    {/each}
  </div>
  <div class="segmented" role="radiogroup" aria-label="Along the side">
    {#each ALIGNS as each (each)}
      <button
        type="button"
        role="radio"
        aria-checked={each === align}
        class="segment"
        onclick={() => (align = each)}
      >
        {each}
      </button>
    {/each}
  </div>
  <label class="range">
    Gap {gap} px
    <input type="range" min={-24} max={32} bind:value={gap} />
  </label>
  <label class="switch">
    <input type="checkbox" bind:checked={pinned} />
    Pinned
  </label>
</div>
<Stage class="stage">
  <RenderLayer />
  <SearchLayer />
  {#snippet overlay()}
    <MatchCard {placement} {gap} {pinned} />
  {/snippet}
</Stage>
```

## 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](../viewing/page-view), anchored UI is placed above everything else on your page,
so a scrolling list or a card can't cut it off.
