---
title: "Menus over the page — Vue"
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: "Vue"
source: "https://www.embedpdf.com/docs/headless/vue/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` slot:

**`stage-overlay.vue`**

```vue
<script setup lang="ts">
import { Anchored } from '@embedpdf/vue/anchored';
import { RenderLayer } from '@embedpdf/vue/render';
import type { SearchHit } from '@embedpdf/vue/search';
import { Stage } from '@embedpdf/vue/stage';
import { highlight } from './highlight';

defineProps<{ hit: SearchHit }>();
</script>

<template>
  <Stage>
    <template #page>
      <RenderLayer />
    </template>
    <template #overlay>
      <Anchored :anchor="{ page: hit.page, bounds: hit.bounds }" placement="bottom">
        <button @click="highlight(hit)">Highlight</button>
      </Anchored>
    </template>
  </Stage>
</template>
```

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

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { stagePlugin } from '@embedpdf/vue/stage';
import { renderPlugin } from '@embedpdf/vue/render';
import { searchPlugin } from '@embedpdf/vue/search';
import { localEngine } from '@embedpdf/engine';
import Pages from './Pages.vue';

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>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Pages />
    </DocumentGate>
  </Viewer>
</template>
```

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

```vue
<script setup lang="ts">
import { Anchored } from '@embedpdf/vue/anchored';
import { useSearch, useSearchState } from '@embedpdf/vue/search';

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

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

**`Pages.vue`**

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

const search = useSearch();

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

<template>
  <Stage class="stage">
    <template #page>
      <RenderLayer />
      <SearchLayer />
    </template>
    <template #overlay>
      <NextMatch />
    </template>
  </Stage>
</template>
```

## 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.vue`**

```vue
<script setup lang="ts">
import { Anchored } from '@embedpdf/vue/anchored';
import { type Annotation, useAnnotationAnchor, useCommentThread } from '@embedpdf/vue/annotation';

const props = defineProps<{ note: Annotation }>();

const anchor = useAnnotationAnchor(() => props.note.ref);
const thread = useCommentThread(() => props.note.ref);
</script>

<template>
  <Anchored :anchor="anchor" placement="right" :gap="4" pinned>
    <span class="badge">{{ thread?.replies.length ?? 0 }}</span>
  </Anchored>
</template>
```

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

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { stagePlugin } from '@embedpdf/vue/stage';
import { renderPlugin } from '@embedpdf/vue/render';
import { searchPlugin } from '@embedpdf/vue/search';
import { localEngine } from '@embedpdf/engine';
import Pages from './Pages.vue';

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>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Pages />
    </DocumentGate>
  </Viewer>
</template>
```

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

```vue
<script setup lang="ts">
import { Anchored } from '@embedpdf/vue/anchored';
import type { AnchoredPlacement } from '@embedpdf/vue/anchored';
import { useSearch, useSearchState } from '@embedpdf/vue/search';

// A card next to the active match, where you put it, `gap` pixels away.
defineProps<{ placement: AnchoredPlacement; gap: number; pinned: boolean }>();

const search = useSearch();
const { activeHit, activeHitIndex, hitCount } = useSearchState();
</script>

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

**`Pages.vue`**

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

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

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

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

<template>
  <div class="toolbar">
    <div class="segmented" role="radiogroup" aria-label="Side">
      <button
        v-for="each in SIDES"
        :key="each"
        type="button"
        role="radio"
        :aria-checked="each === side"
        class="segment"
        @click="side = each"
      >
        {{ each }}
      </button>
    </div>
    <div class="segmented" role="radiogroup" aria-label="Along the side">
      <button
        v-for="each in ALIGNS"
        :key="each"
        type="button"
        role="radio"
        :aria-checked="each === align"
        class="segment"
        @click="align = each"
      >
        {{ each }}
      </button>
    </div>
    <label class="range">
      Gap {{ gap }} px
      <input v-model.number="gap" type="range" :min="-24" :max="32" />
    </label>
    <label class="switch">
      <input v-model="pinned" type="checkbox" />
      Pinned
    </label>
  </div>
  <Stage class="stage">
    <template #page>
      <RenderLayer />
      <SearchLayer />
    </template>
    <template #overlay>
      <MatchCard :placement="placement" :gap="gap" :pinned="pinned" />
    </template>
  </Stage>
</template>
```

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